@rapidmx/web-client 0.20.0 → 0.22.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 (906) hide show
  1. package/README.md +415 -381
  2. package/apps/admin/_layout.tsx +2 -2
  3. package/apps/admin/audit-log/index.tsx +4 -4
  4. package/apps/admin/branding/index.tsx +39 -39
  5. package/apps/admin/data-requests/index.tsx +482 -482
  6. package/apps/admin/distribution-lists/[uid].tsx +3 -3
  7. package/apps/admin/distribution-lists/index.tsx +4 -4
  8. package/apps/admin/distribution-lists/new/index.tsx +5 -5
  9. package/apps/admin/domains/[uid].tsx +166 -166
  10. package/apps/admin/domains/index.tsx +4 -4
  11. package/apps/admin/domains/new/index.tsx +5 -5
  12. package/apps/admin/encryption-policy/index.tsx +1 -1
  13. package/apps/admin/escrow-scopes/[uid].tsx +350 -350
  14. package/apps/admin/escrow-scopes/index.tsx +4 -4
  15. package/apps/admin/escrow-scopes/new/index.tsx +5 -5
  16. package/apps/admin/index.tsx +129 -129
  17. package/apps/admin/ingest-queue/index.tsx +4 -4
  18. package/apps/admin/mailbox-policy/index.tsx +1 -1
  19. package/apps/admin/mailboxes/[uid].tsx +271 -271
  20. package/apps/admin/mailboxes/new/index.tsx +30 -30
  21. package/apps/admin/plugins/index.tsx +15 -15
  22. package/apps/admin/quarantine/index.tsx +5 -5
  23. package/apps/admin/retention-policy/index.tsx +39 -39
  24. package/apps/admin/signing-certificates/index.tsx +343 -343
  25. package/apps/admin/transport-rules/[uid].tsx +5 -5
  26. package/apps/admin/transport-rules/_transportRuleConfig.tsx +3 -3
  27. package/apps/admin/transport-rules/index.tsx +4 -4
  28. package/apps/admin/transport-rules/new/index.tsx +5 -5
  29. package/apps/escrow/_layout.tsx +1 -1
  30. package/apps/escrow/audit-log/index.tsx +196 -196
  31. package/apps/escrow/index.tsx +4 -4
  32. package/apps/escrow/matters/[uid].tsx +621 -621
  33. package/apps/escrow/matters/new/index.tsx +6 -6
  34. package/apps/shared/appearance/AppearanceForm.tsx +3 -3
  35. package/apps/shared/appearance/AppearanceHead.tsx +1 -1
  36. package/apps/shared/appearance/AppearanceProvider.tsx +3 -3
  37. package/apps/shared/appearance/appearanceCache.ts +1 -1
  38. package/apps/shared/appearance/appearanceContext.ts +1 -1
  39. package/apps/shared/appearance/theme.ts +1 -1
  40. package/apps/shared/auth/adminAccess.ts +99 -99
  41. package/apps/shared/calendar/bookingPlugin.ts +1 -1
  42. package/apps/shared/calendar/calendarLiveUpdates.ts +1 -1
  43. package/apps/shared/calendar/calendarReminders.ts +1 -1
  44. package/apps/shared/calendar/useCalendarReminders.ts +1 -1
  45. package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +1 -1
  46. package/apps/shared/components/admin/diagnostics/InstalledPlugins.tsx +2 -2
  47. package/apps/shared/components/admin/diagnostics/LogsPanel.tsx +2 -2
  48. package/apps/shared/components/admin/diagnostics/PackagesTable.tsx +1 -1
  49. package/apps/shared/components/admin/diagnostics/RuntimePanel.tsx +1 -1
  50. package/apps/shared/components/admin/diagnostics/SystemPanel.tsx +2 -2
  51. package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +1 -1
  52. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +1 -1
  53. package/apps/shared/components/admin/diagnostics/format.ts +1 -1
  54. package/apps/shared/components/admin/diagnostics/logClient.ts +1 -1
  55. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +4 -4
  56. package/apps/shared/components/admin/elevation.ts +1 -1
  57. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +3 -3
  58. package/apps/shared/components/admin/layout/AdminShell.tsx +384 -384
  59. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -238
  60. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +154 -152
  61. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -194
  62. package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +1 -1
  63. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +19 -14
  64. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +5 -5
  65. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  66. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +5 -5
  67. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  68. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +2 -2
  69. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +32 -27
  70. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  71. package/apps/shared/components/admin/settings/PluginsManager.tsx +2093 -2093
  72. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  73. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  74. package/apps/shared/components/admin/setup/SetupWizard.tsx +450 -446
  75. package/apps/shared/components/admin/signOut.ts +1 -1
  76. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  77. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +3 -3
  78. package/apps/shared/components/calendar/CalendarListSidebar.tsx +3 -3
  79. package/apps/shared/components/calendar/DescriptionEditor.tsx +1 -1
  80. package/apps/shared/components/calendar/EventDescriptionView.tsx +1 -1
  81. package/apps/shared/components/calendar/EventDetails.tsx +16 -14
  82. package/apps/shared/components/calendar/EventEditor.tsx +54 -39
  83. package/apps/shared/components/calendar/EventExpandedForm.tsx +3 -3
  84. package/apps/shared/components/calendar/EventFormParts.tsx +2 -2
  85. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  86. package/apps/shared/components/calendar/EventQuickForm.tsx +3 -3
  87. package/apps/shared/components/calendar/EventShell.tsx +1 -1
  88. package/apps/shared/components/calendar/FindATime.tsx +6 -4
  89. package/apps/shared/components/calendar/GuestInput.tsx +2 -2
  90. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  91. package/apps/shared/components/calendar/QuickCreateFaces.tsx +1 -1
  92. package/apps/shared/components/calendar/QuickFaceFrame.tsx +1 -1
  93. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  94. package/apps/shared/components/calendar/RequestChangeForm.tsx +8 -6
  95. package/apps/shared/components/calendar/ResourcePicker.tsx +7 -5
  96. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  97. package/apps/shared/components/calendar/TaskQuickForm.tsx +23 -18
  98. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  99. package/apps/shared/components/calendar/allDay.ts +124 -124
  100. package/apps/shared/components/calendar/eventDialogFields.ts +3 -3
  101. package/apps/shared/components/calendar/eventForm.ts +3 -3
  102. package/apps/shared/components/calendar/eventFormat.ts +3 -3
  103. package/apps/shared/components/calendar/findTimeGrid.ts +2 -2
  104. package/apps/shared/components/calendar/layout/CalendarShell.tsx +11 -9
  105. package/apps/shared/components/contacts/ContactDetailPane.tsx +4 -4
  106. package/apps/shared/components/contacts/ContactForm.tsx +393 -383
  107. package/apps/shared/components/contacts/ContactsSidebar.tsx +7 -5
  108. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -103
  109. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -5
  110. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  111. package/apps/shared/components/contacts/layout/ContactsShell.tsx +9 -7
  112. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  113. package/apps/shared/components/forms/StringListField.tsx +1 -1
  114. package/apps/shared/components/layout/AppShell.tsx +465 -461
  115. package/apps/shared/components/layout/BrandingChrome.tsx +1 -1
  116. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  117. package/apps/shared/components/layout/MailboxProvisioning.tsx +170 -168
  118. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +9 -9
  119. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  120. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  121. package/apps/shared/components/layout/UnlockPromptProvider.tsx +6 -6
  122. package/apps/shared/components/layout/UserMenu.tsx +441 -439
  123. package/apps/shared/components/mail/ConversationList.tsx +334 -332
  124. package/apps/shared/components/mail/ConversationThreadPane.tsx +624 -613
  125. package/apps/shared/components/mail/InviteCard.tsx +11 -9
  126. package/apps/shared/components/mail/LazyReadingPane.tsx +2 -2
  127. package/apps/shared/components/mail/MailAddress.tsx +1 -1
  128. package/apps/shared/components/mail/MailListToolbar.tsx +2 -2
  129. package/apps/shared/components/mail/MailSelectionBar.tsx +240 -240
  130. package/apps/shared/components/mail/MenuButton.tsx +404 -404
  131. package/apps/shared/components/mail/MessageDetailPane.tsx +1759 -1757
  132. package/apps/shared/components/mail/MoveToFolderDialog.tsx +8 -6
  133. package/apps/shared/components/mail/OutboxRowStatus.tsx +1 -1
  134. package/apps/shared/components/mail/PermanentDeleteDialog.tsx +2 -2
  135. package/apps/shared/components/mail/compose/ComposeContext.tsx +323 -312
  136. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  137. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1709 -1681
  138. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +4 -4
  139. package/apps/shared/components/mail/compose/EmojiPicker.tsx +2 -2
  140. package/apps/shared/components/mail/compose/GifPicker.tsx +3 -3
  141. package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -1
  142. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  143. package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +1 -1
  144. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  145. package/apps/shared/components/mail/compose/encryptionRequirement.ts +2 -2
  146. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  147. package/apps/shared/components/mail/compose/recipients.ts +1 -1
  148. package/apps/shared/components/mail/invite/InviteRowChip.tsx +2 -2
  149. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +9 -7
  150. package/apps/shared/components/mail/invite/InviteTimeline.tsx +1 -1
  151. package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +2 -2
  152. package/apps/shared/components/mail/invite/inviteFormat.ts +2 -2
  153. package/apps/shared/components/mail/invite/inviteStore.ts +14 -6
  154. package/apps/shared/components/mail/labelMenu.tsx +9 -7
  155. package/apps/shared/components/mail/layout/MailShell.tsx +4 -4
  156. package/apps/shared/components/mail/listPreferences.ts +2 -2
  157. package/apps/shared/components/mail/pinnedSigners.ts +45 -19
  158. package/apps/shared/components/mail/reading/EncryptedBody.tsx +2 -2
  159. package/apps/shared/components/mail/reading/MessageBody.tsx +6 -6
  160. package/apps/shared/components/mail/reading/MessageCard.tsx +3 -3
  161. package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -258
  162. package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -79
  163. package/apps/shared/components/mail/reading/PendingMessageCard.tsx +1 -1
  164. package/apps/shared/components/mail/reading/bodyContent.ts +3 -3
  165. package/apps/shared/components/mail/reading/bodyHtml.ts +1 -1
  166. package/apps/shared/components/mail/reading/messageExport.ts +59 -59
  167. package/apps/shared/components/mail/reading/printMessage.ts +99 -99
  168. package/apps/shared/components/mail/reading/useMessageActions.ts +450 -443
  169. package/apps/shared/components/mail/unreadStyle.tsx +1 -1
  170. package/apps/shared/components/mail/verificationSeals.ts +129 -125
  171. package/apps/shared/components/mail/writableMailboxes.ts +14 -7
  172. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  173. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  174. package/apps/shared/components/settings/layout/SettingsShell.tsx +8 -6
  175. package/apps/shared/components/sharing/PrincipalListField.tsx +1 -1
  176. package/apps/shared/components/sharing/PrincipalPicker.tsx +1 -1
  177. package/apps/shared/components/sharing/PrincipalResolver.tsx +4 -4
  178. package/apps/shared/components/tasks/TasksSidebar.tsx +7 -5
  179. package/apps/shared/components/tasks/layout/TasksShell.tsx +9 -7
  180. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  181. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  182. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  183. package/apps/shared/keyboard/dispatch.ts +124 -124
  184. package/apps/shared/keyboard/format.ts +89 -89
  185. package/apps/shared/keyboard/keymap.ts +114 -114
  186. package/apps/shared/keyboard/registry.ts +65 -65
  187. package/apps/shared/keyboard/targets.ts +79 -79
  188. package/apps/shared/mail/findWellKnownFolderUid.ts +6 -4
  189. package/apps/shared/mail/folderCounts.ts +8 -4
  190. package/apps/shared/mail/folderOfType.ts +51 -49
  191. package/apps/shared/mail/folderTree.ts +143 -143
  192. package/apps/shared/mail/listAllPages.ts +39 -39
  193. package/apps/shared/mail/listSnapshots.ts +2 -2
  194. package/apps/shared/mail/messageReadState.ts +6 -5
  195. package/apps/shared/mail/newMailNotifications.ts +171 -171
  196. package/apps/shared/mail/outbox/composeBridge.ts +2 -2
  197. package/apps/shared/mail/outbox/outboxState.ts +1 -1
  198. package/apps/shared/mail/outbox/outgoingReplies.ts +2 -2
  199. package/apps/shared/mail/outbox/sendDecision.ts +2 -2
  200. package/apps/shared/mail/outbox/sendJob.ts +450 -445
  201. package/apps/shared/mail/outbox/sendOutcomes.ts +160 -155
  202. package/apps/shared/mail/outbox/useOutboxStatus.ts +5 -3
  203. package/apps/shared/mail/permanentDelete.ts +8 -7
  204. package/apps/shared/mail/primaryMailbox.ts +1 -1
  205. package/apps/shared/mail/reportNotices.ts +66 -66
  206. package/apps/shared/mail/senderBlocking.ts +157 -141
  207. package/apps/shared/mail/useMailConnection.ts +207 -205
  208. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  209. package/apps/shared/mail/useMailboxUpdateAccess.ts +84 -60
  210. package/apps/shared/mail/useMarkMessageRead.ts +54 -47
  211. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  212. package/apps/shared/mail/usePermanentDelete.tsx +5 -3
  213. package/apps/shared/navigation/pageTitle.ts +1 -1
  214. package/apps/shared/notifications/NotificationCenter.tsx +1 -1
  215. package/apps/shared/notifications/NotificationHistoryDialog.tsx +2 -2
  216. package/apps/shared/notifications/apiErrors.ts +2 -2
  217. package/apps/shared/notifications/pushStatus.ts +1 -1
  218. package/apps/shared/notifications/store.ts +560 -560
  219. package/apps/shared/notifications/systemErrors.ts +1 -1
  220. package/apps/shared/search/LocalIndexLifecycle.tsx +120 -114
  221. package/apps/shared/search/crossMailboxSearch.ts +3 -3
  222. package/apps/shared/search/localIndexBuilder.ts +493 -481
  223. package/apps/shared/search/localIndexKey.ts +1 -1
  224. package/apps/shared/search/localIndexRpcClient.ts +150 -30
  225. package/apps/shared/search/localIndexTransport.ts +84 -0
  226. package/apps/shared/search/localIndexWorker.ts +1 -1
  227. package/apps/shared/search/searchTier2.ts +3 -3
  228. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  229. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  230. package/apps/shared/signing/enrollmentView.ts +251 -251
  231. package/apps/shared/signing/signingInfo.ts +1 -1
  232. package/apps/shared/signing/useNow.ts +19 -19
  233. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +92 -90
  234. package/apps/shared/styles/app.css +387 -396
  235. package/apps/www/_layout.tsx +2 -2
  236. package/apps/www/calendar/index.tsx +583 -581
  237. package/apps/www/contacts/[uid].tsx +114 -112
  238. package/apps/www/contacts/index.tsx +19 -17
  239. package/apps/www/index.tsx +3021 -3012
  240. package/apps/www/messages/[uid].tsx +141 -139
  241. package/apps/www/settings/auto-reply/index.tsx +138 -136
  242. package/apps/www/settings/blocked-senders/index.tsx +305 -303
  243. package/apps/www/settings/encryption/index.tsx +1296 -1290
  244. package/apps/www/settings/filters/[uid].tsx +184 -179
  245. package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +2 -2
  246. package/apps/www/settings/filters/index.tsx +105 -105
  247. package/apps/www/settings/filters/new/index.tsx +167 -165
  248. package/apps/www/settings/labels/index.tsx +209 -207
  249. package/apps/www/settings/privacy/index.tsx +499 -495
  250. package/apps/www/settings/profile/index.tsx +256 -251
  251. package/apps/www/settings/read-receipts/index.tsx +155 -150
  252. package/apps/www/settings/sharing/index.tsx +259 -259
  253. package/apps/www/settings/signatures/[uid].tsx +175 -175
  254. package/apps/www/settings/signatures/index.tsx +91 -91
  255. package/apps/www/settings/signatures/new/index.tsx +138 -138
  256. package/apps/www/settings/signatures/signatureDefaults.ts +1 -1
  257. package/apps/www/tasks/index.tsx +659 -654
  258. package/dist/apps/admin/_layout.d.ts +1 -1
  259. package/dist/apps/admin/_layout.js +1 -1
  260. package/dist/apps/admin/audit-log/index.d.ts +1 -1
  261. package/dist/apps/admin/audit-log/index.js +4 -4
  262. package/dist/apps/admin/branding/index.js +3 -3
  263. package/dist/apps/admin/data-requests/index.js +8 -8
  264. package/dist/apps/admin/distribution-lists/[uid].js +3 -3
  265. package/dist/apps/admin/distribution-lists/index.js +4 -4
  266. package/dist/apps/admin/distribution-lists/new/index.js +5 -5
  267. package/dist/apps/admin/domains/[uid].js +6 -6
  268. package/dist/apps/admin/domains/index.js +4 -4
  269. package/dist/apps/admin/domains/new/index.js +5 -5
  270. package/dist/apps/admin/encryption-policy/index.js +1 -1
  271. package/dist/apps/admin/escrow-scopes/[uid].d.ts +1 -1
  272. package/dist/apps/admin/escrow-scopes/[uid].js +7 -7
  273. package/dist/apps/admin/escrow-scopes/index.js +4 -4
  274. package/dist/apps/admin/escrow-scopes/new/index.js +5 -5
  275. package/dist/apps/admin/index.js +6 -6
  276. package/dist/apps/admin/ingest-queue/index.js +4 -4
  277. package/dist/apps/admin/mailbox-policy/index.js +1 -1
  278. package/dist/apps/admin/mailboxes/[uid].js +5 -5
  279. package/dist/apps/admin/quarantine/index.js +5 -5
  280. package/dist/apps/admin/retention-policy/index.js +3 -3
  281. package/dist/apps/admin/signing-certificates/index.js +5 -5
  282. package/dist/apps/admin/transport-rules/[uid].js +5 -5
  283. package/dist/apps/admin/transport-rules/_transportRuleConfig.d.ts +1 -1
  284. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +2 -2
  285. package/dist/apps/admin/transport-rules/index.js +4 -4
  286. package/dist/apps/admin/transport-rules/new/index.js +5 -5
  287. package/dist/apps/escrow/_layout.d.ts +1 -1
  288. package/dist/apps/escrow/audit-log/index.d.ts +1 -1
  289. package/dist/apps/escrow/audit-log/index.js +4 -4
  290. package/dist/apps/escrow/index.js +4 -4
  291. package/dist/apps/escrow/matters/[uid].js +10 -10
  292. package/dist/apps/escrow/matters/new/index.js +6 -6
  293. package/dist/apps/shared/appearance/AppearanceForm.js +3 -3
  294. package/dist/apps/shared/appearance/AppearanceHead.js +1 -1
  295. package/dist/apps/shared/appearance/AppearanceProvider.d.ts +1 -1
  296. package/dist/apps/shared/appearance/AppearanceProvider.js +3 -3
  297. package/dist/apps/shared/appearance/appearanceCache.d.ts +1 -1
  298. package/dist/apps/shared/appearance/appearanceCache.js +1 -1
  299. package/dist/apps/shared/appearance/appearanceContext.d.ts +1 -1
  300. package/dist/apps/shared/appearance/theme.d.ts +1 -1
  301. package/dist/apps/shared/auth/adminAccess.js +1 -1
  302. package/dist/apps/shared/calendar/bookingPlugin.js +1 -1
  303. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +1 -1
  304. package/dist/apps/shared/calendar/calendarReminders.d.ts +1 -1
  305. package/dist/apps/shared/calendar/useCalendarReminders.js +1 -1
  306. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +1 -1
  307. package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.d.ts +1 -1
  308. package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.js +2 -2
  309. package/dist/apps/shared/components/admin/diagnostics/LogsPanel.js +2 -2
  310. package/dist/apps/shared/components/admin/diagnostics/PackagesTable.js +1 -1
  311. package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.js +1 -1
  312. package/dist/apps/shared/components/admin/diagnostics/SystemPanel.js +2 -2
  313. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +1 -1
  314. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +1 -1
  315. package/dist/apps/shared/components/admin/diagnostics/format.js +1 -1
  316. package/dist/apps/shared/components/admin/diagnostics/logClient.js +1 -1
  317. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.d.ts +1 -1
  318. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +4 -4
  319. package/dist/apps/shared/components/admin/elevation.js +1 -1
  320. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +3 -3
  321. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  322. package/dist/apps/shared/components/admin/layout/AdminShell.js +8 -8
  323. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +1 -1
  324. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +6 -6
  325. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.d.ts +1 -1
  326. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +10 -8
  327. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +4 -4
  328. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.d.ts +1 -1
  329. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.d.ts +1 -1
  330. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +7 -5
  331. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +5 -5
  332. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +1 -1
  333. package/dist/apps/shared/components/admin/settings/BrandingForm.js +8 -8
  334. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +1 -1
  335. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +5 -5
  336. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +1 -1
  337. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +4 -4
  338. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +2 -2
  339. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.d.ts +1 -1
  340. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +14 -12
  341. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +1 -1
  342. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +4 -4
  343. package/dist/apps/shared/components/admin/settings/PluginsManager.js +5 -5
  344. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +1 -1
  345. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +5 -5
  346. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +7 -7
  347. package/dist/apps/shared/components/admin/setup/SetupWizard.js +26 -22
  348. package/dist/apps/shared/components/admin/signOut.js +1 -1
  349. package/dist/apps/shared/components/admin/usePagedList.js +2 -2
  350. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +3 -3
  351. package/dist/apps/shared/components/calendar/CalendarListSidebar.d.ts +1 -1
  352. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +2 -2
  353. package/dist/apps/shared/components/calendar/DescriptionEditor.js +1 -1
  354. package/dist/apps/shared/components/calendar/EventDescriptionView.js +1 -1
  355. package/dist/apps/shared/components/calendar/EventDetails.d.ts +2 -2
  356. package/dist/apps/shared/components/calendar/EventDetails.js +16 -14
  357. package/dist/apps/shared/components/calendar/EventEditor.d.ts +2 -2
  358. package/dist/apps/shared/components/calendar/EventEditor.js +22 -20
  359. package/dist/apps/shared/components/calendar/EventExpandedForm.js +2 -2
  360. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
  361. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  362. package/dist/apps/shared/components/calendar/EventModal.d.ts +2 -2
  363. package/dist/apps/shared/components/calendar/EventModal.js +3 -3
  364. package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -3
  365. package/dist/apps/shared/components/calendar/EventShell.js +1 -1
  366. package/dist/apps/shared/components/calendar/FindATime.js +6 -4
  367. package/dist/apps/shared/components/calendar/GuestInput.d.ts +2 -2
  368. package/dist/apps/shared/components/calendar/MonthView.d.ts +1 -1
  369. package/dist/apps/shared/components/calendar/MonthView.js +1 -1
  370. package/dist/apps/shared/components/calendar/QuickCreateFaces.js +1 -1
  371. package/dist/apps/shared/components/calendar/QuickFaceFrame.js +1 -1
  372. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +1 -1
  373. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +1 -1
  374. package/dist/apps/shared/components/calendar/RequestChangeForm.js +8 -6
  375. package/dist/apps/shared/components/calendar/ResourcePicker.d.ts +1 -1
  376. package/dist/apps/shared/components/calendar/ResourcePicker.js +7 -5
  377. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +1 -1
  378. package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +1 -1
  379. package/dist/apps/shared/components/calendar/TaskQuickForm.js +9 -7
  380. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +1 -1
  381. package/dist/apps/shared/components/calendar/TimeGridView.js +1 -1
  382. package/dist/apps/shared/components/calendar/allDay.d.ts +2 -2
  383. package/dist/apps/shared/components/calendar/allDay.js +1 -1
  384. package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +2 -2
  385. package/dist/apps/shared/components/calendar/eventDialogFields.js +2 -2
  386. package/dist/apps/shared/components/calendar/eventForm.d.ts +3 -3
  387. package/dist/apps/shared/components/calendar/eventFormat.d.ts +1 -1
  388. package/dist/apps/shared/components/calendar/eventFormat.js +2 -2
  389. package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +1 -1
  390. package/dist/apps/shared/components/calendar/findTimeGrid.js +1 -1
  391. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  392. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +11 -9
  393. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +1 -1
  394. package/dist/apps/shared/components/contacts/ContactDetailPane.js +2 -2
  395. package/dist/apps/shared/components/contacts/ContactForm.d.ts +2 -2
  396. package/dist/apps/shared/components/contacts/ContactForm.js +12 -8
  397. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +1 -1
  398. package/dist/apps/shared/components/contacts/ContactsSidebar.js +7 -5
  399. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +1 -1
  400. package/dist/apps/shared/components/contacts/KeyChangeReview.js +5 -5
  401. package/dist/apps/shared/components/contacts/contactKeys.d.ts +2 -2
  402. package/dist/apps/shared/components/contacts/contactKeys.js +2 -2
  403. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  404. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +9 -7
  405. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -7
  406. package/dist/apps/shared/components/forms/StringListField.js +1 -1
  407. package/dist/apps/shared/components/layout/AppShell.d.ts +2 -2
  408. package/dist/apps/shared/components/layout/AppShell.js +14 -10
  409. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +1 -1
  410. package/dist/apps/shared/components/layout/KeyEnrollmentGate.d.ts +1 -1
  411. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +9 -9
  412. package/dist/apps/shared/components/layout/MailboxProvisioning.js +8 -6
  413. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +1 -1
  414. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +9 -9
  415. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +2 -2
  416. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +5 -5
  417. package/dist/apps/shared/components/layout/UserMenu.js +6 -4
  418. package/dist/apps/shared/components/mail/ConversationList.d.ts +2 -2
  419. package/dist/apps/shared/components/mail/ConversationList.js +6 -4
  420. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +3 -3
  421. package/dist/apps/shared/components/mail/ConversationThreadPane.js +13 -11
  422. package/dist/apps/shared/components/mail/InviteCard.js +10 -8
  423. package/dist/apps/shared/components/mail/LazyReadingPane.js +2 -2
  424. package/dist/apps/shared/components/mail/MailAddress.d.ts +1 -1
  425. package/dist/apps/shared/components/mail/MailAddress.js +1 -1
  426. package/dist/apps/shared/components/mail/MailListToolbar.d.ts +2 -2
  427. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +2 -2
  428. package/dist/apps/shared/components/mail/MenuButton.js +1 -1
  429. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +3 -3
  430. package/dist/apps/shared/components/mail/MessageDetailPane.js +30 -28
  431. package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +1 -1
  432. package/dist/apps/shared/components/mail/MoveToFolderDialog.js +8 -6
  433. package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +1 -1
  434. package/dist/apps/shared/components/mail/PermanentDeleteDialog.js +2 -2
  435. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +10 -1
  436. package/dist/apps/shared/components/mail/compose/ComposeContext.js +4 -2
  437. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +7 -1
  438. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +54 -31
  439. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +4 -4
  440. package/dist/apps/shared/components/mail/compose/EmojiPicker.js +2 -2
  441. package/dist/apps/shared/components/mail/compose/GifPicker.js +3 -3
  442. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +1 -1
  443. package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -1
  444. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +1 -1
  445. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +2 -2
  446. package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +1 -1
  447. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -3
  448. package/dist/apps/shared/components/mail/compose/quotedBody.js +3 -3
  449. package/dist/apps/shared/components/mail/compose/recipients.d.ts +1 -1
  450. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +1 -1
  451. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +1 -1
  452. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +9 -7
  453. package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +1 -1
  454. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +1 -1
  455. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +1 -1
  456. package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +2 -2
  457. package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +9 -3
  458. package/dist/apps/shared/components/mail/invite/inviteStore.js +13 -6
  459. package/dist/apps/shared/components/mail/labelMenu.d.ts +1 -1
  460. package/dist/apps/shared/components/mail/labelMenu.js +9 -7
  461. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +1 -1
  462. package/dist/apps/shared/components/mail/layout/MailShell.js +3 -3
  463. package/dist/apps/shared/components/mail/listPreferences.d.ts +2 -2
  464. package/dist/apps/shared/components/mail/listPreferences.js +1 -1
  465. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +12 -5
  466. package/dist/apps/shared/components/mail/pinnedSigners.js +37 -18
  467. package/dist/apps/shared/components/mail/reading/EncryptedBody.js +2 -2
  468. package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +2 -2
  469. package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -4
  470. package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +1 -1
  471. package/dist/apps/shared/components/mail/reading/MessageCard.js +2 -2
  472. package/dist/apps/shared/components/mail/reading/MessageSourceDialog.js +3 -3
  473. package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +1 -1
  474. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +2 -2
  475. package/dist/apps/shared/components/mail/reading/bodyContent.js +2 -2
  476. package/dist/apps/shared/components/mail/reading/bodyHtml.js +1 -1
  477. package/dist/apps/shared/components/mail/reading/messageExport.js +1 -1
  478. package/dist/apps/shared/components/mail/reading/printMessage.d.ts +2 -2
  479. package/dist/apps/shared/components/mail/reading/printMessage.js +11 -11
  480. package/dist/apps/shared/components/mail/reading/useMessageActions.d.ts +1 -1
  481. package/dist/apps/shared/components/mail/reading/useMessageActions.js +18 -16
  482. package/dist/apps/shared/components/mail/unreadStyle.d.ts +1 -1
  483. package/dist/apps/shared/components/mail/verificationSeals.d.ts +27 -6
  484. package/dist/apps/shared/components/mail/verificationSeals.js +15 -11
  485. package/dist/apps/shared/components/mail/writableMailboxes.d.ts +8 -3
  486. package/dist/apps/shared/components/mail/writableMailboxes.js +12 -6
  487. package/dist/apps/shared/components/rules/RuleBuilder.js +1 -1
  488. package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +1 -1
  489. package/dist/apps/shared/components/settings/SigningCertificateCard.js +3 -3
  490. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  491. package/dist/apps/shared/components/settings/layout/SettingsShell.js +8 -6
  492. package/dist/apps/shared/components/sharing/PrincipalListField.d.ts +1 -1
  493. package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +1 -1
  494. package/dist/apps/shared/components/sharing/PrincipalPicker.js +1 -1
  495. package/dist/apps/shared/components/sharing/PrincipalResolver.d.ts +1 -1
  496. package/dist/apps/shared/components/sharing/PrincipalResolver.js +3 -3
  497. package/dist/apps/shared/components/tasks/TasksSidebar.d.ts +1 -1
  498. package/dist/apps/shared/components/tasks/TasksSidebar.js +7 -5
  499. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  500. package/dist/apps/shared/components/tasks/layout/TasksShell.js +9 -7
  501. package/dist/apps/shared/keyboard/ShortcutsDialog.js +1 -1
  502. package/dist/apps/shared/mail/findWellKnownFolderUid.d.ts +5 -3
  503. package/dist/apps/shared/mail/findWellKnownFolderUid.js +5 -4
  504. package/dist/apps/shared/mail/folderCounts.d.ts +1 -1
  505. package/dist/apps/shared/mail/folderCounts.js +6 -4
  506. package/dist/apps/shared/mail/folderOfType.d.ts +3 -2
  507. package/dist/apps/shared/mail/folderOfType.js +4 -4
  508. package/dist/apps/shared/mail/folderTree.d.ts +1 -1
  509. package/dist/apps/shared/mail/listSnapshots.d.ts +2 -2
  510. package/dist/apps/shared/mail/messageReadState.d.ts +4 -3
  511. package/dist/apps/shared/mail/messageReadState.js +5 -5
  512. package/dist/apps/shared/mail/newMailNotifications.d.ts +1 -1
  513. package/dist/apps/shared/mail/newMailNotifications.js +1 -1
  514. package/dist/apps/shared/mail/outbox/composeBridge.d.ts +2 -2
  515. package/dist/apps/shared/mail/outbox/outboxState.d.ts +1 -1
  516. package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +2 -2
  517. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +2 -2
  518. package/dist/apps/shared/mail/outbox/sendDecision.js +1 -1
  519. package/dist/apps/shared/mail/outbox/sendJob.d.ts +10 -6
  520. package/dist/apps/shared/mail/outbox/sendJob.js +39 -35
  521. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +6 -3
  522. package/dist/apps/shared/mail/outbox/sendOutcomes.js +19 -15
  523. package/dist/apps/shared/mail/outbox/useOutboxStatus.js +5 -3
  524. package/dist/apps/shared/mail/permanentDelete.d.ts +4 -3
  525. package/dist/apps/shared/mail/permanentDelete.js +8 -8
  526. package/dist/apps/shared/mail/primaryMailbox.d.ts +1 -1
  527. package/dist/apps/shared/mail/primaryMailbox.js +1 -1
  528. package/dist/apps/shared/mail/reportNotices.d.ts +1 -1
  529. package/dist/apps/shared/mail/senderBlocking.d.ts +5 -4
  530. package/dist/apps/shared/mail/senderBlocking.js +19 -16
  531. package/dist/apps/shared/mail/useMailConnection.d.ts +1 -1
  532. package/dist/apps/shared/mail/useMailConnection.js +12 -10
  533. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +2 -2
  534. package/dist/apps/shared/mail/useMailLiveUpdates.js +2 -2
  535. package/dist/apps/shared/mail/useMailboxUpdateAccess.d.ts +3 -2
  536. package/dist/apps/shared/mail/useMailboxUpdateAccess.js +31 -10
  537. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +1 -1
  538. package/dist/apps/shared/mail/useMarkMessageRead.js +5 -3
  539. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +1 -1
  540. package/dist/apps/shared/mail/usePermanentDelete.d.ts +1 -1
  541. package/dist/apps/shared/mail/usePermanentDelete.js +4 -2
  542. package/dist/apps/shared/navigation/pageTitle.d.ts +1 -1
  543. package/dist/apps/shared/notifications/NotificationCenter.js +1 -1
  544. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +2 -2
  545. package/dist/apps/shared/notifications/apiErrors.js +2 -2
  546. package/dist/apps/shared/notifications/pushStatus.js +1 -1
  547. package/dist/apps/shared/notifications/systemErrors.js +1 -1
  548. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -2
  549. package/dist/apps/shared/search/LocalIndexLifecycle.js +15 -10
  550. package/dist/apps/shared/search/crossMailboxSearch.d.ts +1 -1
  551. package/dist/apps/shared/search/crossMailboxSearch.js +2 -2
  552. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -3
  553. package/dist/apps/shared/search/localIndexBuilder.js +15 -12
  554. package/dist/apps/shared/search/localIndexKey.js +1 -1
  555. package/dist/apps/shared/search/localIndexRpcClient.d.ts +43 -31
  556. package/dist/apps/shared/search/localIndexRpcClient.js +113 -20
  557. package/dist/apps/shared/search/localIndexTransport.d.ts +77 -0
  558. package/dist/apps/shared/search/localIndexTransport.js +1 -0
  559. package/dist/apps/shared/search/localIndexWorker.d.ts +1 -1
  560. package/dist/apps/shared/search/searchTier2.d.ts +3 -3
  561. package/dist/apps/shared/signing/enrollmentTracker.d.ts +1 -1
  562. package/dist/apps/shared/signing/enrollmentTracker.js +3 -3
  563. package/dist/apps/shared/signing/enrollmentView.d.ts +1 -1
  564. package/dist/apps/shared/signing/signingInfo.d.ts +1 -1
  565. package/dist/apps/shared/signing/signingInfo.js +1 -1
  566. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +1 -1
  567. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +5 -3
  568. package/dist/apps/shared/styles/app.css +387 -396
  569. package/dist/apps/www/_layout.d.ts +1 -1
  570. package/dist/apps/www/_layout.js +1 -1
  571. package/dist/apps/www/calendar/index.d.ts +1 -1
  572. package/dist/apps/www/calendar/index.js +20 -18
  573. package/dist/apps/www/contacts/[uid].d.ts +1 -1
  574. package/dist/apps/www/contacts/[uid].js +8 -6
  575. package/dist/apps/www/contacts/index.d.ts +1 -1
  576. package/dist/apps/www/contacts/index.js +19 -17
  577. package/dist/apps/www/index.d.ts +1 -1
  578. package/dist/apps/www/index.js +54 -50
  579. package/dist/apps/www/messages/[uid].d.ts +1 -1
  580. package/dist/apps/www/messages/[uid].js +11 -9
  581. package/dist/apps/www/settings/appearance/index.d.ts +1 -1
  582. package/dist/apps/www/settings/auto-reply/index.d.ts +1 -1
  583. package/dist/apps/www/settings/auto-reply/index.js +6 -4
  584. package/dist/apps/www/settings/blocked-senders/index.d.ts +1 -1
  585. package/dist/apps/www/settings/blocked-senders/index.js +6 -4
  586. package/dist/apps/www/settings/encryption/index.d.ts +1 -1
  587. package/dist/apps/www/settings/encryption/index.js +31 -29
  588. package/dist/apps/www/settings/filters/[uid].d.ts +1 -1
  589. package/dist/apps/www/settings/filters/[uid].js +11 -9
  590. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.d.ts +2 -2
  591. package/dist/apps/www/settings/filters/index.d.ts +1 -1
  592. package/dist/apps/www/settings/filters/index.js +4 -4
  593. package/dist/apps/www/settings/filters/new/index.d.ts +1 -1
  594. package/dist/apps/www/settings/filters/new/index.js +10 -8
  595. package/dist/apps/www/settings/labels/index.d.ts +1 -1
  596. package/dist/apps/www/settings/labels/index.js +12 -10
  597. package/dist/apps/www/settings/privacy/index.d.ts +1 -1
  598. package/dist/apps/www/settings/privacy/index.js +18 -14
  599. package/dist/apps/www/settings/profile/index.d.ts +1 -1
  600. package/dist/apps/www/settings/profile/index.js +7 -5
  601. package/dist/apps/www/settings/read-receipts/index.d.ts +1 -1
  602. package/dist/apps/www/settings/read-receipts/index.js +5 -3
  603. package/dist/apps/www/settings/sharing/index.d.ts +1 -1
  604. package/dist/apps/www/settings/sharing/index.js +6 -6
  605. package/dist/apps/www/settings/signatures/[uid].d.ts +1 -1
  606. package/dist/apps/www/settings/signatures/[uid].js +5 -5
  607. package/dist/apps/www/settings/signatures/index.d.ts +1 -1
  608. package/dist/apps/www/settings/signatures/index.js +4 -4
  609. package/dist/apps/www/settings/signatures/new/index.d.ts +1 -1
  610. package/dist/apps/www/settings/signatures/new/index.js +4 -4
  611. package/dist/apps/www/settings/signatures/signatureDefaults.d.ts +1 -1
  612. package/dist/apps/www/settings/signatures/signatureDefaults.js +1 -1
  613. package/dist/apps/www/tasks/index.d.ts +1 -1
  614. package/dist/apps/www/tasks/index.js +17 -15
  615. package/dist/lib/admin/auditLogApi.d.ts +32 -0
  616. package/dist/lib/admin/auditLogApi.js +27 -0
  617. package/dist/lib/admin/distributionListsApi.d.ts +47 -0
  618. package/dist/lib/admin/distributionListsApi.js +34 -0
  619. package/dist/lib/admin/domainsApi.d.ts +75 -0
  620. package/dist/lib/admin/domainsApi.js +44 -0
  621. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +79 -0
  622. package/dist/lib/admin/escrowAccessRequestsApi.js +46 -0
  623. package/dist/lib/admin/escrowAuditLogApi.d.ts +66 -0
  624. package/dist/lib/admin/escrowAuditLogApi.js +31 -0
  625. package/dist/lib/admin/escrowScopesApi.d.ts +78 -0
  626. package/dist/lib/admin/escrowScopesApi.js +50 -0
  627. package/dist/lib/admin/leftoverMailboxApi.d.ts +73 -0
  628. package/dist/lib/admin/leftoverMailboxApi.js +67 -0
  629. package/dist/lib/admin/mailboxPolicyApi.d.ts +27 -0
  630. package/dist/lib/admin/mailboxPolicyApi.js +21 -0
  631. package/dist/lib/admin/matterExportApi.d.ts +40 -0
  632. package/dist/lib/admin/matterExportApi.js +39 -0
  633. package/dist/lib/admin/matterSearchApi.d.ts +20 -0
  634. package/dist/lib/admin/matterSearchApi.js +27 -0
  635. package/dist/lib/admin/mattersApi.d.ts +56 -0
  636. package/dist/lib/admin/mattersApi.js +40 -0
  637. package/dist/lib/admin/pluginsApi.d.ts +265 -0
  638. package/dist/lib/admin/pluginsApi.js +102 -0
  639. package/dist/lib/admin/retentionPolicyApi.d.ts +32 -0
  640. package/dist/lib/admin/retentionPolicyApi.js +29 -0
  641. package/dist/lib/admin/setupApi.d.ts +23 -0
  642. package/dist/lib/admin/setupApi.js +26 -0
  643. package/dist/lib/admin/transportRulesApi.d.ts +66 -0
  644. package/dist/lib/admin/transportRulesApi.js +43 -0
  645. package/dist/lib/appearance/preferencesApi.d.ts +128 -0
  646. package/dist/lib/appearance/preferencesApi.js +257 -0
  647. package/dist/lib/auth/profileApi.d.ts +33 -0
  648. package/dist/lib/auth/profileApi.js +52 -0
  649. package/dist/lib/auth/session.d.ts +70 -0
  650. package/dist/lib/auth/session.js +210 -0
  651. package/dist/lib/branding/brandingApi.d.ts +47 -0
  652. package/dist/lib/branding/brandingApi.js +61 -0
  653. package/dist/lib/branding/useBranding.d.ts +23 -0
  654. package/dist/lib/branding/useBranding.js +65 -0
  655. package/dist/lib/calendar/calendarApi.d.ts +230 -0
  656. package/dist/lib/calendar/calendarApi.js +106 -0
  657. package/dist/lib/calendar/calendarColors.d.ts +18 -0
  658. package/dist/lib/calendar/calendarColors.js +39 -0
  659. package/dist/lib/calendar/calendarDragIds.d.ts +42 -0
  660. package/dist/lib/calendar/calendarDragIds.js +92 -0
  661. package/dist/lib/calendar/calendarMutations.d.ts +68 -0
  662. package/dist/lib/calendar/calendarMutations.js +190 -0
  663. package/dist/lib/calendar/eventDescription.d.ts +43 -0
  664. package/dist/lib/calendar/eventDescription.js +195 -0
  665. package/dist/lib/calendar/freeBusyApi.d.ts +100 -0
  666. package/dist/lib/calendar/freeBusyApi.js +119 -0
  667. package/dist/lib/calendar/inviteApi.d.ts +121 -0
  668. package/dist/lib/calendar/inviteApi.js +54 -0
  669. package/dist/lib/calendar/recurrence.d.ts +60 -0
  670. package/dist/lib/calendar/recurrence.js +225 -0
  671. package/dist/lib/components/avatar/ContactAvatar.d.ts +8 -0
  672. package/dist/lib/components/avatar/ContactAvatar.js +26 -0
  673. package/dist/lib/components/buttons/Button.d.ts +9 -0
  674. package/dist/lib/components/buttons/Button.js +14 -0
  675. package/dist/lib/components/buttons/CopyButton.d.ts +18 -0
  676. package/dist/lib/components/buttons/CopyButton.js +14 -0
  677. package/dist/lib/components/feedback/Alert.d.ts +4 -0
  678. package/dist/lib/components/feedback/Alert.js +4 -0
  679. package/dist/lib/components/feedback/Skeleton.d.ts +21 -0
  680. package/dist/lib/components/feedback/Skeleton.js +14 -0
  681. package/dist/lib/components/forms/FormField.d.ts +8 -0
  682. package/dist/lib/components/forms/FormField.js +5 -0
  683. package/dist/lib/components/navigation/BottomTabBar.d.ts +27 -0
  684. package/dist/lib/components/navigation/BottomTabBar.js +32 -0
  685. package/dist/lib/components/overlays/Drawer.d.ts +20 -0
  686. package/dist/lib/components/overlays/Drawer.js +27 -0
  687. package/dist/lib/components/overlays/Modal.d.ts +17 -0
  688. package/dist/lib/components/overlays/Modal.js +25 -0
  689. package/dist/lib/components/overlays/PopoverPortal.d.ts +23 -0
  690. package/dist/lib/components/overlays/PopoverPortal.js +53 -0
  691. package/dist/lib/components/overlays/overlayStack.d.ts +31 -0
  692. package/dist/lib/components/overlays/overlayStack.js +143 -0
  693. package/dist/lib/components/pickers/MiniDatePicker.d.ts +13 -0
  694. package/dist/lib/components/pickers/MiniDatePicker.js +42 -0
  695. package/dist/lib/contacts/contactsApi.d.ts +174 -0
  696. package/dist/lib/contacts/contactsApi.js +139 -0
  697. package/dist/lib/contacts/vcard.d.ts +23 -0
  698. package/dist/lib/contacts/vcard.js +179 -0
  699. package/dist/lib/crypto/composeSecurity.d.ts +67 -0
  700. package/dist/lib/crypto/composeSecurity.js +93 -0
  701. package/dist/lib/crypto/encoding.d.ts +9 -0
  702. package/dist/lib/crypto/encoding.js +32 -0
  703. package/dist/lib/crypto/escrowKeys.d.ts +25 -0
  704. package/dist/lib/crypto/escrowKeys.js +59 -0
  705. package/dist/lib/crypto/idleTimeout.d.ts +16 -0
  706. package/dist/lib/crypto/idleTimeout.js +47 -0
  707. package/dist/lib/crypto/keySession.d.ts +159 -0
  708. package/dist/lib/crypto/keySession.js +367 -0
  709. package/dist/lib/crypto/keys.d.ts +50 -0
  710. package/dist/lib/crypto/keys.js +89 -0
  711. package/dist/lib/crypto/keyvaultApi.d.ts +394 -0
  712. package/dist/lib/crypto/keyvaultApi.js +350 -0
  713. package/dist/lib/crypto/masterKey.d.ts +59 -0
  714. package/dist/lib/crypto/masterKey.js +104 -0
  715. package/dist/lib/crypto/masterKeyWraps.d.ts +108 -0
  716. package/dist/lib/crypto/masterKeyWraps.js +228 -0
  717. package/dist/lib/crypto/messageSecurity.d.ts +248 -0
  718. package/dist/lib/crypto/messageSecurity.js +367 -0
  719. package/dist/lib/crypto/mime.d.ts +143 -0
  720. package/dist/lib/crypto/mime.js +504 -0
  721. package/dist/lib/crypto/passkeyUnlock.d.ts +20 -0
  722. package/dist/lib/crypto/passkeyUnlock.js +79 -0
  723. package/dist/lib/crypto/passwordUnlock.d.ts +39 -0
  724. package/dist/lib/crypto/passwordUnlock.js +73 -0
  725. package/dist/lib/crypto/recoveryCode.d.ts +28 -0
  726. package/dist/lib/crypto/recoveryCode.js +86 -0
  727. package/dist/lib/crypto/signingProviderApi.d.ts +95 -0
  728. package/dist/lib/crypto/signingProviderApi.js +60 -0
  729. package/dist/lib/crypto/smime.d.ts +128 -0
  730. package/dist/lib/crypto/smime.js +402 -0
  731. package/dist/lib/crypto/smimeMessage.d.ts +132 -0
  732. package/dist/lib/crypto/smimeMessage.js +341 -0
  733. package/dist/lib/crypto/useIdleKeyTimeout.d.ts +10 -0
  734. package/dist/lib/crypto/useIdleKeyTimeout.js +163 -0
  735. package/dist/lib/crypto/verificationSeal.d.ts +56 -0
  736. package/dist/lib/crypto/verificationSeal.js +220 -0
  737. package/dist/lib/mail/compose/composeQuoting.d.ts +88 -0
  738. package/dist/lib/mail/compose/composeQuoting.js +204 -0
  739. package/dist/lib/mail/compose/emojiData.d.ts +9 -0
  740. package/dist/lib/mail/compose/emojiData.js +18 -0
  741. package/dist/lib/mail/compose/giphyApi.d.ts +17 -0
  742. package/dist/lib/mail/compose/giphyApi.js +20 -0
  743. package/dist/lib/mail/conversationsApi.d.ts +80 -0
  744. package/dist/lib/mail/conversationsApi.js +44 -0
  745. package/dist/lib/mail/dataExportApi.d.ts +50 -0
  746. package/dist/lib/mail/dataExportApi.js +40 -0
  747. package/dist/lib/mail/directoryApi.d.ts +49 -0
  748. package/dist/lib/mail/directoryApi.js +101 -0
  749. package/dist/lib/mail/erasureRequestApi.d.ts +45 -0
  750. package/dist/lib/mail/erasureRequestApi.js +43 -0
  751. package/dist/lib/mail/flaggedMessages.d.ts +22 -0
  752. package/dist/lib/mail/flaggedMessages.js +73 -0
  753. package/dist/lib/mail/focusedInboxOverridesApi.d.ts +37 -0
  754. package/dist/lib/mail/focusedInboxOverridesApi.js +34 -0
  755. package/dist/lib/mail/labelsApi.d.ts +47 -0
  756. package/dist/lib/mail/labelsApi.js +38 -0
  757. package/dist/lib/mail/mailAddress.d.ts +27 -0
  758. package/dist/lib/mail/mailAddress.js +54 -0
  759. package/dist/lib/mail/mailApi.d.ts +850 -0
  760. package/dist/lib/mail/mailApi.js +602 -0
  761. package/dist/lib/mail/mailDetailHooks.d.ts +10 -0
  762. package/dist/lib/mail/mailDetailHooks.js +53 -0
  763. package/dist/lib/mail/mailFilterRulesApi.d.ts +76 -0
  764. package/dist/lib/mail/mailFilterRulesApi.js +45 -0
  765. package/dist/lib/mail/mailSignaturesApi.d.ts +49 -0
  766. package/dist/lib/mail/mailSignaturesApi.js +42 -0
  767. package/dist/lib/mail/mailboxAccessApi.d.ts +74 -0
  768. package/dist/lib/mail/mailboxAccessApi.js +56 -0
  769. package/dist/lib/mail/mailboxImportApi.d.ts +53 -0
  770. package/dist/lib/mail/mailboxImportApi.js +51 -0
  771. package/dist/lib/mail/messageBodySanitizer.d.ts +16 -0
  772. package/dist/lib/mail/messageBodySanitizer.js +118 -0
  773. package/dist/lib/mail/pushClient.d.ts +106 -0
  774. package/dist/lib/mail/pushClient.js +317 -0
  775. package/dist/lib/mail/sendEvents.d.ts +33 -0
  776. package/dist/lib/mail/sendEvents.js +44 -0
  777. package/dist/lib/mail/sendFailure.d.ts +16 -0
  778. package/dist/lib/mail/sendFailure.js +86 -0
  779. package/dist/lib/mail/senderListsApi.d.ts +69 -0
  780. package/dist/lib/mail/senderListsApi.js +126 -0
  781. package/dist/lib/search/queryGrammar.d.ts +51 -0
  782. package/dist/lib/search/queryGrammar.js +111 -0
  783. package/dist/lib/search/searchApi.d.ts +100 -0
  784. package/dist/lib/search/searchApi.js +107 -0
  785. package/dist/lib/search/searchScoring.d.ts +34 -0
  786. package/dist/lib/search/searchScoring.js +44 -0
  787. package/dist/lib/search/searchTier3.d.ts +64 -0
  788. package/dist/lib/search/searchTier3.js +335 -0
  789. package/dist/lib/tasks/tasksApi.d.ts +85 -0
  790. package/dist/lib/tasks/tasksApi.js +55 -0
  791. package/dist/lib/util/api.d.ts +160 -0
  792. package/dist/lib/util/api.js +251 -0
  793. package/dist/lib/util/apiClientContext.d.ts +7 -0
  794. package/dist/lib/util/apiClientContext.js +32 -0
  795. package/dist/lib/util/apiQuery.d.ts +32 -0
  796. package/dist/lib/util/apiQuery.js +42 -0
  797. package/dist/lib/util/clipboard.d.ts +7 -0
  798. package/dist/lib/util/clipboard.js +56 -0
  799. package/dist/lib/util/dateInput.d.ts +6 -0
  800. package/dist/lib/util/dateInput.js +14 -0
  801. package/dist/lib/util/timeZone.d.ts +12 -0
  802. package/dist/lib/util/timeZone.js +32 -0
  803. package/dist/lib/util/useCopyToClipboard.d.ts +12 -0
  804. package/dist/lib/util/useCopyToClipboard.js +34 -0
  805. package/dist/lib/util/useIsMobile.d.ts +10 -0
  806. package/dist/lib/util/useIsMobile.js +29 -0
  807. package/dist/lib/videoconf/videoMeetingsApi.d.ts +116 -0
  808. package/dist/lib/videoconf/videoMeetingsApi.js +49 -0
  809. package/lib/admin/auditLogApi.ts +48 -0
  810. package/lib/admin/distributionListsApi.ts +77 -0
  811. package/lib/admin/domainsApi.ts +113 -0
  812. package/lib/admin/escrowAccessRequestsApi.ts +110 -0
  813. package/lib/admin/escrowAuditLogApi.ts +101 -0
  814. package/lib/admin/escrowScopesApi.ts +112 -0
  815. package/lib/admin/leftoverMailboxApi.ts +120 -0
  816. package/lib/admin/mailboxPolicyApi.ts +38 -0
  817. package/lib/admin/matterExportApi.ts +62 -0
  818. package/lib/admin/matterSearchApi.ts +35 -0
  819. package/lib/admin/mattersApi.ts +86 -0
  820. package/lib/admin/pluginsApi.ts +339 -0
  821. package/lib/admin/retentionPolicyApi.ts +46 -0
  822. package/lib/admin/setupApi.ts +40 -0
  823. package/lib/admin/transportRulesApi.ts +106 -0
  824. package/lib/appearance/preferencesApi.ts +335 -0
  825. package/lib/auth/profileApi.ts +77 -0
  826. package/lib/auth/session.ts +238 -0
  827. package/lib/branding/brandingApi.ts +97 -0
  828. package/lib/branding/useBranding.ts +81 -0
  829. package/lib/calendar/calendarApi.ts +309 -0
  830. package/lib/calendar/calendarColors.ts +45 -0
  831. package/lib/calendar/calendarDragIds.ts +112 -0
  832. package/lib/calendar/calendarMutations.ts +236 -0
  833. package/lib/calendar/eventDescription.ts +211 -0
  834. package/lib/calendar/freeBusyApi.ts +197 -0
  835. package/lib/calendar/inviteApi.ts +154 -0
  836. package/lib/calendar/recurrence.ts +285 -0
  837. package/lib/components/avatar/ContactAvatar.tsx +47 -0
  838. package/lib/components/buttons/Button.tsx +40 -0
  839. package/lib/components/buttons/CopyButton.tsx +45 -0
  840. package/lib/components/feedback/Alert.tsx +13 -0
  841. package/lib/components/feedback/Skeleton.tsx +38 -0
  842. package/lib/components/forms/FormField.tsx +23 -0
  843. package/lib/components/navigation/BottomTabBar.tsx +66 -0
  844. package/lib/components/overlays/Drawer.tsx +72 -0
  845. package/lib/components/overlays/Modal.tsx +64 -0
  846. package/lib/components/overlays/PopoverPortal.tsx +74 -0
  847. package/lib/components/overlays/overlayStack.ts +165 -0
  848. package/lib/components/pickers/MiniDatePicker.tsx +90 -0
  849. package/lib/contacts/contactsApi.ts +289 -0
  850. package/lib/contacts/vcard.ts +197 -0
  851. package/lib/crypto/composeSecurity.ts +140 -0
  852. package/lib/crypto/encoding.ts +36 -0
  853. package/lib/crypto/escrowKeys.ts +74 -0
  854. package/lib/crypto/idleTimeout.ts +49 -0
  855. package/lib/crypto/keySession.ts +483 -0
  856. package/lib/crypto/keys.ts +105 -0
  857. package/lib/crypto/keyvaultApi.ts +674 -0
  858. package/lib/crypto/masterKey.ts +134 -0
  859. package/lib/crypto/masterKeyWraps.ts +269 -0
  860. package/lib/crypto/messageSecurity.ts +600 -0
  861. package/lib/crypto/mime.ts +568 -0
  862. package/lib/crypto/passkeyUnlock.ts +103 -0
  863. package/lib/crypto/passwordUnlock.ts +98 -0
  864. package/lib/crypto/recoveryCode.ts +93 -0
  865. package/lib/crypto/signingProviderApi.ts +135 -0
  866. package/lib/crypto/smime.ts +459 -0
  867. package/lib/crypto/smimeMessage.ts +491 -0
  868. package/lib/crypto/useIdleKeyTimeout.ts +176 -0
  869. package/lib/crypto/verificationSeal.ts +288 -0
  870. package/lib/mail/compose/composeQuoting.ts +243 -0
  871. package/lib/mail/compose/emojiData.ts +34 -0
  872. package/lib/mail/compose/giphyApi.ts +31 -0
  873. package/lib/mail/conversationsApi.ts +123 -0
  874. package/lib/mail/dataExportApi.ts +73 -0
  875. package/lib/mail/directoryApi.ts +161 -0
  876. package/lib/mail/erasureRequestApi.ts +71 -0
  877. package/lib/mail/flaggedMessages.ts +80 -0
  878. package/lib/mail/focusedInboxOverridesApi.ts +64 -0
  879. package/lib/mail/labelsApi.ts +71 -0
  880. package/lib/mail/mailAddress.ts +76 -0
  881. package/lib/mail/mailApi.ts +1286 -0
  882. package/lib/mail/mailDetailHooks.ts +57 -0
  883. package/lib/mail/mailFilterRulesApi.ts +116 -0
  884. package/lib/mail/mailSignaturesApi.ts +84 -0
  885. package/lib/mail/mailboxAccessApi.ts +106 -0
  886. package/lib/mail/mailboxImportApi.ts +87 -0
  887. package/lib/mail/messageBodySanitizer.ts +129 -0
  888. package/lib/mail/pushClient.ts +375 -0
  889. package/lib/mail/sendEvents.ts +72 -0
  890. package/lib/mail/sendFailure.ts +98 -0
  891. package/lib/mail/senderListsApi.ts +161 -0
  892. package/lib/search/queryGrammar.ts +179 -0
  893. package/lib/search/searchApi.ts +199 -0
  894. package/lib/search/searchScoring.ts +46 -0
  895. package/lib/search/searchTier3.ts +406 -0
  896. package/lib/tasks/tasksApi.ts +128 -0
  897. package/lib/util/api.ts +318 -0
  898. package/lib/util/apiClientContext.ts +35 -0
  899. package/lib/util/apiQuery.ts +61 -0
  900. package/lib/util/clipboard.ts +55 -0
  901. package/lib/util/dateInput.ts +15 -0
  902. package/lib/util/timeZone.ts +33 -0
  903. package/lib/util/useCopyToClipboard.ts +48 -0
  904. package/lib/util/useIsMobile.ts +34 -0
  905. package/lib/videoconf/videoMeetingsApi.ts +144 -0
  906. package/package.json +10 -4
@@ -1,1681 +1,1709 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { ChangeEvent, useEffect, useRef, useState } from "react";
6
- import {
7
- HiOutlineArrowsPointingIn,
8
- HiOutlineArrowsPointingOut,
9
- HiOutlineChevronDown,
10
- HiOutlineLockClosed,
11
- HiOutlineMinus,
12
- HiOutlinePaperClip,
13
- HiOutlineTrash,
14
- HiOutlineXMark,
15
- } from "react-icons/hi2";
16
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
17
- import {
18
- Attachment,
19
- ComposeRecipientInput,
20
- Mailbox,
21
- Message,
22
- assembleDraft,
23
- attachmentContentUrl,
24
- createDraft,
25
- deleteMessage,
26
- getMailbox,
27
- getMessage,
28
- listFolders,
29
- listMailboxes,
30
- uploadAttachment,
31
- } from "@rapidmx/react-shared/mail/mailApi.js";
32
- import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
33
- import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
34
- import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
35
- import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
36
- import { resolveRecipientEncryption, RecipientEncryptionStatus } from "@rapidmx/react-shared/crypto/composeSecurity.js";
37
- import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
38
- import { EncryptionPolicy, findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
39
- import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
40
- import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
41
- import { notify } from "../../../notifications/store.js";
42
- import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
43
- import { decideSend, SendBlock } from "../../../mail/outbox/sendDecision.js";
44
- import { SendRequest, sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
45
- import { useCompose, type ComposeSession } from "./ComposeContext.js";
46
- import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
47
- import { SHORTCUTS, ShortcutDef } from "../../../keyboard/keymap.js";
48
- import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
49
- import { useShortcut } from "../../../keyboard/useShortcut.js";
50
- import { isSigningOut, registerComposeFlush } from "./composeFlushRegistry.js";
51
- import { markComposePhase } from "./composePerf.js";
52
- import RecipientInput from "./RecipientInput.js";
53
- import { parseRecipientList } from "./recipients.js";
54
- import RichTextEditor from "./RichTextEditor.js";
55
- import ScheduleSendPicker from "./ScheduleSendPicker.js";
56
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
57
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
58
- import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
59
-
60
- export interface ComposeWindowProps {
61
- session: ComposeSession;
62
- onClose: () => void;
63
- onToggleMinimize: () => void;
64
- /** The signed-in user - identifies their own ("primary") mailbox, the default From when the session
65
- * doesn't name a mailbox. */
66
- userUid?: string;
67
- /** The caller holds a trusted (admin) role - every mailbox is writable, so no per-mailbox access checks. */
68
- trusted?: boolean;
69
- /** How long (ms) after the last edit the draft is autosaved. Defaults to `DEFAULT_AUTOSAVE_DELAY_MS`;
70
- * only overridden by tests. */
71
- autosaveDelayMs?: number;
72
- /** Backoff (ms) between automatic retries of a failed mailbox/encryption-policy load, one entry per retry.
73
- * Defaults to `DEFAULT_CRYPTO_RETRY_DELAYS_MS`; only overridden by tests. */
74
- cryptoRetryDelaysMs?: number[];
75
- }
76
-
77
- /** Debounce between the last edit and the draft autosave. */
78
- export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
79
-
80
- /** Automatic, silent retries of a failed mailbox/encryption-policy (or recipient key) load, one entry per retry - a flapping gateway is waited out
81
- * in the background, without a word to the user. */
82
- export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000, 60000];
83
-
84
- /** Thrown by `deleteDraft()` when the server copy is no longer this window's draft (sent or scheduled elsewhere). */
85
- const NO_LONGER_A_DRAFT_MESSAGE = "This message was already sent or scheduled from another window, so it wasn't deleted.";
86
-
87
- /** Whether `fresh` (a re-read of a draft) is still an unsent draft in `folderUid`: never delete a message another
88
- * window has since sent, scheduled, or started sending. */
89
- function isStillDraft(fresh: Message, folderUid: string): boolean {
90
- return fresh.folderUid === folderUid && !fresh.scheduledSendTime && !fresh.scheduledSendLeaseExpiresAt && !fresh.scheduledSendRelayedAt;
91
- }
92
-
93
- const SAVE_STATUS_LABEL = { idle: "", saving: "Saving…", saved: "Draft saved", error: "Couldn't save draft" } as const;
94
-
95
- const DISCARD_TITLE = "Discard this draft?";
96
- /** Shown only for a message that is actually headed for encryption (asked for, or applied by the loaded policy) - see `encryptionRequirement.ts`. */
97
- const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so closing this window discards what you've written.";
98
-
99
- /** The confirmation shown before a Close/Discard throws content away (or when a Close couldn't save it). */
100
- interface ClosePrompt {
101
- title: string;
102
- message: string;
103
- }
104
-
105
- const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
106
- const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
107
-
108
- const MIN_WIDTH = 320;
109
- const MIN_HEIGHT = 320;
110
- // Kept well clear of the viewport edge, not flush against it — this is a resize *ceiling* (the drag
111
- // handles below clamp to it), not the "expanded" preset's own size, which stays a plain `85vh`
112
- // Tailwind class precisely so it never needs `window.innerHeight` at all: that'd have to be read at
113
- // module-evaluation time to live alongside these other constants, and `window` doesn't exist yet
114
- // during this app's SSR pass (see `ReactRoute`) — referencing it here would crash every page render,
115
- // not just this component's.
116
- const VIEWPORT_MARGIN = 24;
117
-
118
- interface Size {
119
- width: number;
120
- height: number;
121
- }
122
-
123
- type ResizeEdge = "left" | "top" | "corner";
124
-
125
- /** A To/Cc/Bcc field's recipients - see `recipients.ts` for the format. */
126
- function parseAddresses(value: string): ComposeRecipientInput[] {
127
- return parseRecipientList(value);
128
- }
129
-
130
- /**
131
- * Normalizes an address into the key `recipientStatuses`/the in-flight and failure trackers below use -
132
- * matching `classifyRecipientTier()`'s own lowercase-domain comparison (`@rapidmx/react-shared`'s
133
- * `composeSecurity.ts`), but applied to the whole address rather than just the domain. Without this, the
134
- * same mailbox typed with different casing across To/Cc/Bcc (plausible via autocomplete vs. a pasted
135
- * signature block) is tracked as two independent recipients: if one lookup lags or fails while the other
136
- * succeeds, `decideMessageEncryption()`'s all-or-nothing check sees a spurious unresolved recipient and
137
- * denies auto-encryption even though every *real* recipient resolved fine. Only ever used as an internal
138
- * cache key - `ComposeRecipientInput.address` itself (what is actually sent as the recipient) is untouched.
139
- */
140
- function addressCacheKey(address: string): string {
141
- return address.trim().toLowerCase();
142
- }
143
-
144
- function HeaderButton({
145
- label,
146
- onClick,
147
- icon: Icon,
148
- disabled,
149
- shortcut,
150
- }: {
151
- label: string;
152
- onClick: () => void;
153
- icon: React.ComponentType<{ size?: number }>;
154
- disabled?: boolean;
155
- /** The keyboard shortcut that does the same, named in the tooltip and `aria-keyshortcuts`. */
156
- shortcut?: ShortcutDef;
157
- }) {
158
- const env = useKeyEnvironment();
159
- return (
160
- <button
161
- type="button"
162
- aria-label={label}
163
- title={shortcut ? withHint(label, shortcut, env) : label}
164
- aria-keyshortcuts={shortcut ? ariaKeyShortcuts(shortcut, env) : undefined}
165
- onClick={onClick}
166
- disabled={disabled}
167
- className="w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
168
- >
169
- <Icon size={14} />
170
- </button>
171
- );
172
- }
173
-
174
- /**
175
- * A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
176
- * navigating to a dedicated page (see `ComposeContext.tsx`'s doc comment for why: a full-page compose
177
- * form meant losing your place in the inbox/calendar/contacts view behind it, and every navigation to
178
- * `/compose` re-ran this whole app's full mailbox/folder resolution waterfall from scratch). Owns its
179
- * own draft lifecycle end to end (unlike the old page, which read the Drafts folder from `MailShell`'s
180
- * context — this window can be opened from apps that never mount `MailShell` at all, e.g. Contacts'
181
- * "Email" action, so it resolves its own Drafts folder from just a `mailboxUid`).
182
- *
183
- * Below the `md` breakpoint, a non-minimized session instead renders as a full-screen sheet (there's no
184
- * room for a floating window, and the click-drag resize handles below become meaningless full-screen) —
185
- * see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
186
- * once.
187
- */
188
- export default function ComposeWindow({
189
- session,
190
- onClose,
191
- onToggleMinimize,
192
- userUid,
193
- trusted,
194
- autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS,
195
- cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS,
196
- }: ComposeWindowProps) {
197
- const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late, resume } = session;
198
- // What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
199
- // only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
200
- const baselineTo = late?.to ?? initialTo;
201
- const baselineCc = late?.cc ?? initialCc;
202
- useEffect(() => {
203
- markComposePhase(id, "shell");
204
- markComposePhase(id, "chunk");
205
- }, []);
206
- // The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
207
- // compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
208
- // Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
209
- const [fromMailboxUid, setFromMailboxUid] = useState<string | undefined>(session.mailboxUid);
210
- const mailboxUid = fromMailboxUid;
211
- const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
212
- // Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
213
- // Drafts folder - so once anything has been uploaded the sender can no longer be switched.
214
- const [hasUploads, setHasUploads] = useState(!!resume && resume.attachments.length > 0);
215
- const isMobile = useIsMobile();
216
- const { requestUnlock } = useUnlockPrompt();
217
- const { openCompose } = useCompose();
218
- const keyEnv = useKeyEnvironment();
219
- // Bumped after a successful on-demand unlock to force a re-render - `getUnlockedKeys()` below is a
220
- // plain read from a module-level store, not React state, so nothing else would pick up the change.
221
- const [, setUnlockRefresh] = useState(0);
222
-
223
- const windowRef = useRef<HTMLDivElement>(null);
224
- const [draftsFolderUid, setDraftsFolderUid] = useState<string | undefined>();
225
- const [folderError, setFolderError] = useState<string | null>(null);
226
- // A window re-opened around a message that was already composed (a failed send's "Open draft") continues that server draft and starts with
227
- // every field as it was typed; its body is not seeded from a signature or a quote.
228
- const [draft, setDraft] = useState<Message | null>(resume?.draft ?? null);
229
- // Drafts replaced by a From switch, awaiting deletion until their replacement has actually been created.
230
- const supersededDraftsRef = useRef<Message[]>([]);
231
- const [draftError, setDraftError] = useState<string | null>(null);
232
- const [expanded, setExpanded] = useState(false);
233
- const [manualSize, setManualSize] = useState<Size | null>(null);
234
- const [to, setTo] = useState(resume?.to ?? initialTo ?? "");
235
- const [cc, setCc] = useState(resume?.cc ?? initialCc ?? "");
236
- const [bcc, setBcc] = useState(resume?.bcc ?? "");
237
- const [showCcBcc, setShowCcBcc] = useState(!!(resume ? resume.cc || resume.bcc : initialCc));
238
- const [subject, setSubject] = useState(resume?.subject ?? initialSubject ?? "");
239
- const [html, setHtml] = useState(resume?.html ?? "");
240
- const [contentReady, setContentReady] = useState(!!resume);
241
- const [attachments, setAttachments] = useState<Attachment[]>(resume?.attachments ?? []);
242
- const [attachError, setAttachError] = useState<string | null>(null);
243
- // A problem with the message as composed (no recipient, an attachment that can't be signed): shown here, and the window stays open. What
244
- // goes wrong after Send was accepted is a pop-up instead (see `sendJob.ts`).
245
- const [sendError, setSendError] = useState<string | null>(null);
246
- // Send was pressed while an attachment was still uploading: the window waits (and says so) rather than send without it.
247
- const [waitingForUploads, setWaitingForUploads] = useState(false);
248
- const [requestReceipt, setRequestReceipt] = useState(!!resume?.requestReceipt);
249
- const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
250
- const scheduleButtonRef = useRef<HTMLButtonElement>(null);
251
- const [mailbox, setMailbox] = useState<Mailbox | null>(null);
252
- const [encryptionPolicy, setEncryptionPolicy] = useState<EncryptionPolicy | null>(null);
253
- // Defaults off when replying to a detected mailing list (spec's own "Mailing lists" note under
254
- // Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
255
- // `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
256
- // still turn it back on; this only changes the default.
257
- const [signEnabled, setSignEnabled] = useState(resume ? resume.signEnabled : !suppressSigning);
258
- // A reply to or forward of an encrypted message starts (and, after a From switch, restarts) with encryption
259
- // requested - see `OpenComposeInput.encrypt`.
260
- const [encryptRequested, setEncryptRequested] = useState(resume ? resume.encryptRequested : !!initialEncrypt);
261
- // Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
262
- // recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
263
- // just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
264
- // never cleared, so switching focus between To/Cc/Bcc repeatedly doesn't re-trigger lookups.
265
- const [recipientStatuses, setRecipientStatuses] = useState<Record<string, RecipientEncryptionStatus>>({});
266
- // Addresses whose lookup is on the wire (so the blur handlers and the prefilled-recipient effect don't
267
- // look one up twice), and a counter bumped by a From switch so a lookup for the previous sender is dropped.
268
- const lookupsInFlightRef = useRef(new Set<string>());
269
- const discoveryGenerationRef = useRef(0);
270
- // A failed lookup stores no status: the recipient is simply "not known to be encryptable" (fail open - see `encryptionRequirement.ts`), and
271
- // the lookup is retried in the background with the same backoff as the mailbox/policy load, without a word to the user.
272
- const lookupFailuresRef = useRef(new Map<string, number>());
273
- const lookupRetryTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
274
- const [lookupRetryToken, setLookupRetryToken] = useState(0);
275
- // A send refused *before it left* because signing/encryption was asked for (or the loaded policy applies it) but can't happen right now
276
- // (keys locked, Bcc recipients on an encrypted message, a recipient without a key) - never a silent downgrade to plaintext; the user
277
- // has to explicitly pick the override.
278
- const [securityBlock, setSecurityBlock] = useState<SendBlock | null>(null);
279
- // The schedule time of the send that got blocked (undefined for an immediate send), so the banners'
280
- // override buttons replay the same kind of send the user originally asked for.
281
- const [blockedScheduleIso, setBlockedScheduleIso] = useState<string | undefined>();
282
- // Which crypto toggles were ever shown during this compose session (per sending mailbox). Keys that
283
- // lock after that point must block the send rather than quietly dropping the signature/encryption.
284
- const offeredCryptoRef = useRef({ sign: false, encrypt: false });
285
-
286
- // Draft autosave / discard bookkeeping.
287
- const [seededHtml, setSeededHtml] = useState<string | undefined>();
288
- const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
289
- const [closePrompt, setClosePrompt] = useState<ClosePrompt | null>(null);
290
- /** A Close (saving) or Discard (deleting) is on the wire - the window closes once it succeeds. */
291
- const [closing, setClosing] = useState(false);
292
- const [discardError, setDiscardError] = useState<string | null>(null);
293
- /** Why the last save failed, cleared by the next successful one. */
294
- const saveErrorRef = useRef<string | null>(null);
295
- /** `<draftUid>:<content key>` of the last successful save. */
296
- const lastSavedRef = useRef<string | null>(null);
297
- /** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
298
- const pendingSaveRef = useRef(false);
299
- /** Send was pressed and is being validated/handed over: a second press (a double click, the shortcut held or repeated) does nothing. */
300
- const submittingRef = useRef(false);
301
- /** Attachment/inline-image uploads on the wire, and who is waiting for them to finish (Send pressed meanwhile). */
302
- const uploadsInFlightRef = useRef(0);
303
- const uploadWaitersRef = useRef<(() => void)[]>([]);
304
- /** The latest save request, if any - each save is chained after the one before it. Always settles (never
305
- * rejects), to the saved message, or `undefined` when that save failed. */
306
- const saveInFlightRef = useRef<Promise<Message | undefined>>(Promise.resolve(undefined));
307
- /** Set once the window has been sent/closed/discarded - nothing more gets autosaved after that. */
308
- const finishedRef = useRef(false);
309
-
310
- // A mailbox with no keys enrolled yet just means sign/encrypt stay unavailable for this compose session -
311
- // encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
312
- // The mailbox and the encryption policy load in the background and *nothing waits for them*: until both have loaded, the message is treated as
313
- // not encrypted - its draft saves, Close and Send work, and no message about it is shown (see `encryptionRequirement.ts`). A failed load is
314
- // retried silently with backoff (`cryptoRetryDelaysMs`); if it later turns out the message must be encrypted, `encryptionDecided` (below) starts
315
- // to apply - see the transition rules there.
316
-
317
- useEffect(() => {
318
- let cancelled = false;
319
- listMailboxes({ limit: 100 })
320
- .then((result) => {
321
- if (cancelled) {
322
- return;
323
- }
324
- // Listed with the caller's own (primary) mailbox first, and defaulted to below.
325
- const ordered = orderMailboxes(result, userUid);
326
- setMailboxes(ordered);
327
- // Never default to a mailbox already known to be view-only (see the effect below for one
328
- // that turns out to be).
329
- const candidates = ordered.filter((mb) => peekMailboxWritability(mb, userUid, trusted) !== false);
330
- setFromMailboxUid((current) => current ?? primaryMailboxUid(candidates, userUid));
331
- })
332
- .catch((err) => {
333
- // Only fatal when there's no mailbox to fall back on - a session that already names one just
334
- // loses the From picker.
335
- if (!cancelled && !session.mailboxUid) {
336
- setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
337
- }
338
- });
339
- return () => {
340
- cancelled = true;
341
- };
342
- }, []);
343
-
344
- useEffect(() => {
345
- if (!mailboxUid) {
346
- return;
347
- }
348
- let cancelled = false;
349
- let timer: ReturnType<typeof setTimeout> | undefined;
350
- // A retry only fetches what's still missing.
351
- let mailboxLoaded = mailbox?.uid === mailboxUid;
352
- let policyLoaded = !!encryptionPolicy;
353
- let retries = 0;
354
- async function load() {
355
- await Promise.all([
356
- mailboxLoaded ||
357
- getMailbox(mailboxUid!).then(
358
- (result) => {
359
- mailboxLoaded = true;
360
- if (!cancelled) {
361
- setMailbox(result);
362
- }
363
- },
364
- () => undefined,
365
- ),
366
- policyLoaded ||
367
- getEncryptionPolicy().then(
368
- (result) => {
369
- policyLoaded = true;
370
- if (!cancelled) {
371
- setEncryptionPolicy(result);
372
- }
373
- },
374
- () => undefined,
375
- ),
376
- ]);
377
- if (!cancelled && (!mailboxLoaded || !policyLoaded) && retries < cryptoRetryDelaysMs.length) {
378
- timer = setTimeout(() => void load(), cryptoRetryDelaysMs[retries++]);
379
- }
380
- }
381
- void load();
382
- return () => {
383
- cancelled = true;
384
- clearTimeout(timer);
385
- };
386
- }, [mailboxUid]);
387
-
388
- useEffect(() => {
389
- if (!mailboxUid) {
390
- return;
391
- }
392
- // Cancellable so a slow response for a mailbox the user has since switched away from can't point
393
- // the new draft at the wrong mailbox's Drafts folder.
394
- let cancelled = false;
395
- listFolders(mailboxUid)
396
- .then((folders) => {
397
- if (!cancelled) {
398
- setDraftsFolderUid(folders.find((f) => f.type === "drafts")?.uid);
399
- }
400
- })
401
- .catch((err) => {
402
- if (!cancelled) {
403
- setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder.");
404
- }
405
- });
406
- return () => {
407
- cancelled = true;
408
- };
409
- }, [mailboxUid]);
410
-
411
- /**
412
- * Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
413
- * caching the result so the small per-recipient indicator below the recipient fields can update
414
- * without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
415
- * and not the lookup the background send (`sendJob.ts`) performs again at send time (deliberately: this is a
416
- * best-effort UI hint, not something a stale value here should be trusted to skip at send time).
417
- * A mailbox/policy fetch that hasn't resolved yet just leaves that address with no indicator - the effect
418
- * below runs it again for every current recipient once both have loaded. A failed lookup leaves it without
419
- * a status too, and is retried silently after `cryptoRetryDelaysMs` (then only on blur).
420
- *
421
- * The statuses also drive `encryptionRequirement.ts`: only when every recipient has one, and the loaded policy
422
- * auto-encrypts for all of them, does the message turn into one that is never saved as a plaintext draft.
423
- */
424
- function checkRecipientDiscovery(addresses: ComposeRecipientInput[]) {
425
- if (!mailbox || !encryptionPolicy) {
426
- return;
427
- }
428
- const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
429
- const generation = discoveryGenerationRef.current;
430
- const inFlight = lookupsInFlightRef.current;
431
- const unchecked = addresses.filter((r) => !(addressCacheKey(r.address) in recipientStatuses) && !inFlight.has(addressCacheKey(r.address)));
432
- for (const recipient of unchecked) {
433
- const key = addressCacheKey(recipient.address);
434
- inFlight.add(key);
435
- void lookupKeys(mailbox.uid, recipient.address)
436
- .then(
437
- (lookup) => ({ lookup }),
438
- () => null,
439
- )
440
- .then((result) => {
441
- inFlight.delete(key);
442
- if (generation !== discoveryGenerationRef.current) {
443
- return;
444
- }
445
- if (!result) {
446
- noteLookupFailed(key);
447
- return;
448
- }
449
- lookupFailuresRef.current.delete(key);
450
- const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
451
- setRecipientStatuses((prev) => ({ ...prev, [key]: status }));
452
- });
453
- }
454
- }
455
-
456
- /** Schedules the next automatic, silent retry of a failed lookup (until the backoff has run out; a later blur tries again). */
457
- function noteLookupFailed(address: string) {
458
- const failures = (lookupFailuresRef.current.get(address) ?? 0) + 1;
459
- lookupFailuresRef.current.set(address, failures);
460
- if (failures > cryptoRetryDelaysMs.length) {
461
- return;
462
- }
463
- const timer = setTimeout(() => {
464
- lookupRetryTimersRef.current.delete(timer);
465
- setLookupRetryToken((n) => n + 1);
466
- }, cryptoRetryDelaysMs[failures - 1]);
467
- lookupRetryTimersRef.current.add(timer);
468
- }
469
-
470
- useEffect(
471
- () => () => {
472
- for (const timer of lookupRetryTimersRef.current) {
473
- clearTimeout(timer);
474
- }
475
- },
476
- [],
477
- );
478
-
479
- // Prefilled recipients (a reply's To/Cc) are never blurred, and a blur before the mailbox/policy loaded
480
- // was ignored - look every current recipient up as soon as both are available (and again on a lookup retry).
481
- useEffect(() => {
482
- checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
483
- }, [mailbox, encryptionPolicy, lookupRetryToken]);
484
-
485
- // Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it (not for a window re-opened around a composed message)
486
- // plus any quoted original message (laid out by `buildComposeBodyHtml()`: an empty first paragraph for the
487
- // caret, then the signature, then the quote), before `RichTextEditor` ever mounts (gated by `contentReady`
488
- // below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
489
- // content and never re-syncs from a later prop change, so this has to resolve before that first
490
- // mount, not after. A signature-list failure is best-effort, same as every other supplementary,
491
- // non-blocking fetch in this codebase (e.g. `ConversationList`'s own child-message fetch) — no
492
- // signature is a completely legitimate outcome, so this falls back to just the quoted content
493
- // (if any) rather than surfacing an error over what's a cosmetic nicety.
494
- //
495
- // A reply or forward opens before its quoted original has been fetched (`quotePending`, see
496
- // `OpenComposeInput.pending`). The body is seeded as soon as the signature is here, without the quote, so the editor is usable
497
- // at once however slow the original is to come; the quote is added at the end of the editor's document when it arrives
498
- // (`appendHtml` below) - the same place, under the signature, that a body seeded with it would have had it.
499
- const [defaultSignature, setDefaultSignature] = useState<{ html?: string } | undefined>();
500
- useEffect(() => {
501
- // Fetched once, for whichever mailbox is the sender when compose opens - switching From later must not
502
- // overwrite what the user has already written (the editor itself never re-syncs).
503
- if (!mailboxUid || contentReady || defaultSignature) {
504
- return;
505
- }
506
- listMailSignatures(mailboxUid)
507
- .then((signatures) => {
508
- const signature = signatures.find((s) =>
509
- signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward,
510
- );
511
- setDefaultSignature({ html: signature?.contentHtml });
512
- })
513
- .catch(() => setDefaultSignature({}));
514
- }, [mailboxUid, signatureContext]);
515
- const [seededWithoutQuote, setSeededWithoutQuote] = useState(false);
516
- useEffect(() => {
517
- if (contentReady || !defaultSignature) {
518
- return;
519
- }
520
- const seeded = buildComposeBodyHtml(defaultSignature.html, quotePending ? undefined : initialQuotedHtml);
521
- setHtml(seeded);
522
- setSeededHtml(seeded);
523
- setSeededWithoutQuote(!!quotePending);
524
- setContentReady(true);
525
- markComposePhase(id, "body");
526
- }, [defaultSignature, quotePending, initialQuotedHtml]);
527
- /** What the editor adds to the end of its document once the quote a reply was opened without has arrived. */
528
- const lateQuoteHtml =
529
- seededWithoutQuote && !quotePending && initialQuotedHtml && defaultSignature
530
- ? buildComposeBodyHtml(defaultSignature.html, initialQuotedHtml).slice(buildComposeBodyHtml(defaultSignature.html) === "" ? "<p></p>".length : buildComposeBodyHtml(defaultSignature.html).length)
531
- : undefined;
532
-
533
- useEffect(() => {
534
- if (!mailboxUid || !draftsFolderUid || draft) {
535
- return;
536
- }
537
- let cancelled = false;
538
- // `session.threading` is what makes a reply a reply: the server writes it into the relayed MIME's
539
- // `In-Reply-To`/`References` and groups the message into the thread being answered. Recorded on
540
- // every draft this window creates, including the replacement one a From switch makes - the thread
541
- // doesn't change because the sending mailbox did.
542
- createDraft(mailboxUid, draftsFolderUid, session.threading)
543
- .then((created) => {
544
- if (cancelled) {
545
- // The sender changed while this was in flight - discard the now-orphaned draft.
546
- void deleteMessage(created.uid, created.version).catch(() => undefined);
547
- return;
548
- }
549
- setDraft(created);
550
- markComposePhase(id, "draft");
551
- // Only now that the replacement exists is it safe to discard the draft(s) a From switch
552
- // superseded - if creating this one had failed, the earlier draft is still there.
553
- for (const superseded of supersededDraftsRef.current.splice(0)) {
554
- void deleteSupersededDraft(superseded);
555
- }
556
- })
557
- .catch((err) => {
558
- if (!cancelled) {
559
- setDraftError(err instanceof ApiRequestError ? err.message : "Could not start a new draft.");
560
- }
561
- });
562
- return () => {
563
- cancelled = true;
564
- };
565
- }, [mailboxUid, draftsFolderUid, draft, session.threading]);
566
-
567
- // Drafts a From switch superseded are otherwise only deleted once their replacement exists - closing the
568
- // window (or sending, which closes it) first would orphan them.
569
- useEffect(
570
- () => () => {
571
- for (const superseded of supersededDraftsRef.current.splice(0)) {
572
- void deleteSupersededDraft(superseded);
573
- }
574
- },
575
- [],
576
- );
577
-
578
- /**
579
- * Deletes a draft a From switch replaced. An autosave of it may still be on the wire - it bumps the
580
- * draft's version (see `saveDraftNow()`, which keeps `superseded.version` current while it's queued), so
581
- * the delete waits for it and uses its version; a delete that still fails (e.g. another save of it landed
582
- * meanwhile) is retried once with the version the server currently holds - unless it's no longer a draft
583
- * (another window sent or scheduled it), which is left alone.
584
- * Best-effort, never rejects.
585
- */
586
- async function deleteSupersededDraft(superseded: Message): Promise<void> {
587
- const saved = await saveInFlightRef.current;
588
- // Once it's out of `supersededDraftsRef` a landing save can't update `superseded.version` any more.
589
- const version = saved?.uid === superseded.uid ? saved.version : superseded.version;
590
- try {
591
- await deleteMessage(superseded.uid, version);
592
- } catch {
593
- try {
594
- const fresh = await getMessage(superseded.uid);
595
- if (isStillDraft(fresh, superseded.folderUid)) {
596
- await deleteMessage(fresh.uid, fresh.version);
597
- }
598
- } catch {
599
- // Already gone, or the server is unreachable - nothing more to do from here.
600
- }
601
- }
602
- }
603
-
604
- // The From picker lists every mailbox straight away and drops view-only ones as each check answers - only
605
- // mailboxes the caller can create a draft in are worth offering.
606
- const writability = useMailboxWritability(mailboxes, userUid, trusted);
607
- const fromOptions = mailboxes.filter((mb) => mb.uid === mailboxUid || writability[mb.uid] !== false);
608
-
609
- // The sender turned out to be view-only (e.g. a reply to a message in a mailbox shared read-only, or a
610
- // first-listed shared mailbox): switch to one the caller can actually send from, preferring their own.
611
- const fromIsViewOnly = !!mailboxUid && writability[mailboxUid] === false;
612
- useEffect(() => {
613
- if (!fromIsViewOnly || hasUploads) {
614
- return;
615
- }
616
- const ordered = orderMailboxes(mailboxes, userUid);
617
- const alternative = ordered.find((mb) => writability[mb.uid] === true) ?? ordered.find((mb) => writability[mb.uid] === undefined);
618
- if (alternative) {
619
- handleFromChange(alternative.uid);
620
- }
621
- }, [fromIsViewOnly, writability]);
622
-
623
- /** Switches the sending mailbox: supersedes the current (unsent, upload-free) draft and resets the
624
- * per-mailbox state, so the effects above start a fresh draft and crypto context in the new mailbox -
625
- * the old draft is deleted only once the new one has been created. Recipients, subject, and body are
626
- * kept. */
627
- function handleFromChange(nextMailboxUid: string) {
628
- if (nextMailboxUid === mailboxUid || hasUploads) {
629
- return;
630
- }
631
- if (draft) {
632
- supersededDraftsRef.current.push(draft);
633
- }
634
- setDraft(null);
635
- setDraftsFolderUid(undefined);
636
- setDraftError(null);
637
- setFolderError(null);
638
- setMailbox(null);
639
- setRecipientStatuses({});
640
- discoveryGenerationRef.current += 1;
641
- lookupsInFlightRef.current = new Set();
642
- lookupFailuresRef.current = new Map();
643
- setEncryptRequested(!!initialEncrypt);
644
- setSecurityBlock(null);
645
- offeredCryptoRef.current = { sign: false, encrypt: false };
646
- setFromMailboxUid(nextMailboxUid);
647
- }
648
-
649
- /**
650
- * Click-and-drag resize, from the window's own top/left edges (and the top-left corner, for both
651
- * at once) — the window is anchored bottom-right (see `ComposeContext.tsx`'s stack), so growing it
652
- * always means extending up and/or left, never down/right. `onMove`/`onUp` are fresh closures
653
- * created per drag gesture (captured over this gesture's own start position/size) rather than
654
- * stable component-level functions, specifically so `removeEventListener` in `onUp` always removes
655
- * the exact listener `onMove`/`onUp` themselves just added — a version defined once per render and
656
- * reused across gestures would need extra ref-juggling to avoid removing a stale (already-replaced)
657
- * listener instead of the current one.
658
- */
659
- function handleResizeStart(edge: ResizeEdge) {
660
- return (e: React.PointerEvent) => {
661
- e.preventDefault();
662
- const rect = windowRef.current!.getBoundingClientRect();
663
- const startX = e.clientX;
664
- const startY = e.clientY;
665
- const startWidth = rect.width;
666
- const startHeight = rect.height;
667
-
668
- function onMove(ev: PointerEvent) {
669
- const maxWidth = window.innerWidth - VIEWPORT_MARGIN;
670
- const maxHeight = window.innerHeight - VIEWPORT_MARGIN;
671
- const width = edge === "top" ? startWidth : Math.min(Math.max(startWidth - (ev.clientX - startX), MIN_WIDTH), maxWidth);
672
- const height = edge === "left" ? startHeight : Math.min(Math.max(startHeight - (ev.clientY - startY), MIN_HEIGHT), maxHeight);
673
- setManualSize({ width, height });
674
- }
675
- function onUp() {
676
- window.removeEventListener("pointermove", onMove);
677
- window.removeEventListener("pointerup", onUp);
678
- }
679
- window.addEventListener("pointermove", onMove);
680
- window.addEventListener("pointerup", onUp);
681
- };
682
- }
683
-
684
- function toggleExpanded() {
685
- setExpanded((v) => !v);
686
- setManualSize(null);
687
- }
688
-
689
- /** Uploads `file` as an attachment on the current draft, resolving to a URL the editor can preview
690
- * it at immediately — see `BaseMailComposeRoute.rewriteInlineImageSources()`'s doc comment for how
691
- * that URL gets swapped for the message's real inline `cid:` reference at send time. Resolves to
692
- * `null` (surfacing `attachError` itself, reusing the same state "Attach files" already has) rather
693
- * than throwing: unlike "Attach files" (`disabled={!draft}`), "Insert image" in the formatting
694
- * toolbar is only gated on the *editor* being mounted, not on the draft existing yet — those two
695
- * things load in parallel, so a real (if narrow) window exists where the button is clickable before
696
- * `draft` resolves, and this needs to fail gracefully rather than crash on a null draft.
697
- */
698
- async function handleUploadImage(file: File): Promise<string | null> {
699
- if (!draft) {
700
- setAttachError("Please wait for the draft to finish loading before inserting an image.");
701
- return null;
702
- }
703
- uploadStarted();
704
- try {
705
- const attachment = await uploadAttachment(draft.uid, file);
706
- setHasUploads(true);
707
- void refreshDraftVersion(draft.uid);
708
- return attachmentContentUrl(attachment.uid);
709
- } catch (err) {
710
- setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
711
- return null;
712
- } finally {
713
- uploadFinished();
714
- }
715
- }
716
-
717
- /** Send waits for every upload started before it: see `submit()`. */
718
- function uploadStarted() {
719
- uploadsInFlightRef.current += 1;
720
- }
721
- function uploadFinished() {
722
- uploadsInFlightRef.current -= 1;
723
- if (uploadsInFlightRef.current === 0) {
724
- for (const resolve of uploadWaitersRef.current.splice(0)) {
725
- resolve();
726
- }
727
- }
728
- }
729
-
730
- /** Uploading an attachment bumps the draft's version server-side (it records `hasAttachments`), so a later
731
- * Discard would otherwise send a stale `?version=`. Best-effort: Discard also retries with the server's
732
- * current version. */
733
- async function refreshDraftVersion(uid: string) {
734
- try {
735
- const fresh = await getMessage(uid);
736
- setDraft((prev) => (prev?.uid === fresh.uid && fresh.version > prev.version ? { ...prev, version: fresh.version } : prev));
737
- } catch {
738
- // Discard's own retry covers a version this couldn't refresh.
739
- }
740
- }
741
-
742
- // The "Attach files" input is itself `disabled` until `draft` resolves (see its `disabled={!draft}`
743
- // below), so this can only ever fire once `draft` is set — no defensive null check needed, matching
744
- // this codebase's established pattern for the same class of "always non-null by the time it's
745
- // called" value (e.g. `ComposeToolbar.run()`). No separate empty-`files` guard either: the loop
746
- // below is already a no-op when there's nothing to iterate.
747
- async function handleFilesSelected(e: ChangeEvent<HTMLInputElement>) {
748
- const files = Array.from(e.target.files ?? []);
749
- e.target.value = "";
750
- setAttachError(null);
751
- uploadStarted();
752
- try {
753
- for (const file of files) {
754
- try {
755
- const attachment = await uploadAttachment(draft!.uid, file);
756
- setHasUploads(true);
757
- setAttachments((prev) => [...prev, attachment]);
758
- void refreshDraftVersion(draft!.uid);
759
- } catch (err) {
760
- setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
761
- }
762
- }
763
- } finally {
764
- uploadFinished();
765
- }
766
- }
767
-
768
- /**
769
- * Send (or, given `scheduledSendTimeIso`, Send later) - and get out of the way. The window is the place the message is *written*; nothing
770
- * about getting it to the server is waited for here.
771
- *
772
- * 1. **Validate synchronously**, from what the window already knows (no request): a recipient is required; and if the message is one
773
- * that must be signed or encrypted and can't be right now (keys locked, Bcc on an encrypted message, a recipient with no key, an
774
- * attachment that can't be signed) that is shown right here and the window stays open - the sender fixes it or picks the override.
775
- * An attachment still uploading is waited for, inline (Send says so), never left behind.
776
- * 2. **Close immediately** (`onClose()`): `startSend()` (see `sendJob.ts`) takes over with a snapshot of the message. From here the only
777
- * feedback is the Outbox indicator; a failure at any stage is a sticky pop-up with the reason and Retry / Open draft.
778
- *
779
- * Pressed twice (a double click, the shortcut repeated) it sends once: the first press sets `submittingRef` before any `await`, and
780
- * `startSend()` itself refuses a draft that is already being sent. `forcePlaintext` is the override buttons' choice.
781
- */
782
- async function submit(forcePlaintext: boolean, scheduledSendTimeIso?: string) {
783
- if (submittingRef.current || finishedRef.current) {
784
- return;
785
- }
786
- if (parseAddresses(latestRef.current.to).length === 0) {
787
- setSendError("At least one recipient is required.");
788
- return;
789
- }
790
- submittingRef.current = true;
791
- setSendError(null);
792
- setSecurityBlock(null);
793
- try {
794
- if (uploadsInFlightRef.current > 0) {
795
- setWaitingForUploads(true);
796
- await new Promise<void>((resolve) => uploadWaitersRef.current.push(resolve));
797
- // The editor puts an image it has just uploaded into the message a moment after the upload resolves: let the window catch up.
798
- await new Promise((resolve) => setTimeout(resolve, 0));
799
- setWaitingForUploads(false);
800
- }
801
- const current = latestRef.current;
802
- const request: SendRequest = {
803
- draft: current.draft!,
804
- mailboxUid: current.mailboxUid!,
805
- mailbox: current.mailbox ?? undefined,
806
- policy: current.encryptionPolicy ?? undefined,
807
- toText: current.to,
808
- ccText: current.cc,
809
- bccText: current.bcc,
810
- to: parseAddresses(current.to),
811
- cc: parseAddresses(current.cc),
812
- bcc: parseAddresses(current.bcc),
813
- subject: current.subject,
814
- html: current.html,
815
- attachments: current.attachments,
816
- requestReceipt: current.requestReceipt,
817
- scheduledSendTime: scheduledSendTimeIso,
818
- signEnabled: current.signEnabled,
819
- offeredSign: offeredCryptoRef.current.sign,
820
- offeredEncrypt: offeredCryptoRef.current.encrypt,
821
- encryptRequested: current.encryptRequested,
822
- forcePlaintext,
823
- saved: saveInFlightRef.current,
824
- threading: session.threading,
825
- };
826
- if (request.to.length === 0) {
827
- setSendError("At least one recipient is required.");
828
- return;
829
- }
830
- const early = decideSend(sendDecisionInput(request, request.mailbox, request.policy, current.statuses, false));
831
- if (early.action === "rejected") {
832
- setSendError(early.message);
833
- return;
834
- }
835
- if (early.action === "blocked") {
836
- setBlockedScheduleIso(scheduledSendTimeIso);
837
- setSecurityBlock(early.block);
838
- if (early.block.keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
839
- void handleUnlockForCrypto();
840
- }
841
- return;
842
- }
843
- pendingSaveRef.current = false;
844
- finishedRef.current = true;
845
- if (!startSend(request)) {
846
- finishedRef.current = false;
847
- setSendError("This message is already being sent.");
848
- return;
849
- }
850
- onClose();
851
- } finally {
852
- submittingRef.current = false;
853
- setWaitingForUploads(false);
854
- }
855
- }
856
-
857
- function handleScheduleSend(scheduledSendTimeIso: string) {
858
- setSchedulePickerOpen(false);
859
- void submit(false, scheduledSendTimeIso);
860
- }
861
-
862
- async function handleUnlockForCrypto() {
863
- try {
864
- // Only reachable when hasEnrolledSigningKey/hasEnrolledEncryptionKey found a real enrolled key
865
- // in mailbox.keys (the unlock button below, or a blocked send()), so neither is empty/undefined here.
866
- await requestUnlock(mailbox!.uid, mailbox!.keys!);
867
- setUnlockRefresh((n) => n + 1);
868
- } catch {
869
- // User dismissed the unlock dialog - nothing to do, the toggles below simply stay hidden.
870
- }
871
- }
872
-
873
- /**
874
- * Saves the window's current recipients/subject/body onto its server draft via `assembleDraft()` (the
875
- * same plaintext assembly a normal send uses). Chained after any save already on the wire - two
876
- * overlapping assemblies of the same draft race on its version and the later one is rejected - and reads
877
- * `latestRef` only once it actually runs, so it saves what was last typed (skipping the request when the
878
- * save before it already stored exactly that). Whether a save may happen at all is re-checked at that same
879
- * moment: while it waited, the message may have become one that must not be stored as plaintext (Encrypt
880
- * turned on, a recipient that auto-encrypts), or the window may have been sent or finished - then it
881
- * resolves `{ skipped: true }` without a request (`message` then being the previous save's result). Never
882
- * rejects; `message` is `undefined` when the save failed.
883
- */
884
- function saveDraftNow(): Promise<{ message?: Message; skipped?: boolean }> {
885
- const target = latestRef.current.draft!;
886
- const previous = saveInFlightRef.current;
887
- pendingSaveRef.current = false;
888
- setSaveStatus("saving");
889
- const outcome = (async (): Promise<{ message?: Message; skipped?: boolean }> => {
890
- const before = await previous;
891
- const current = latestRef.current;
892
- if (current.autosaveSuppressed || finishedRef.current) {
893
- setSaveStatus("idle");
894
- // Still the newest copy the server holds, for the version a later delete needs.
895
- return { skipped: true, message: before };
896
- }
897
- const savedKey = `${target.uid}:${current.contentKey}`;
898
- if (before?.uid === target.uid && lastSavedRef.current === savedKey) {
899
- setSaveStatus("saved");
900
- return { message: before };
901
- }
902
- try {
903
- const saved = await assembleDraft(target.uid, {
904
- to: parseAddresses(current.to),
905
- cc: parseAddresses(current.cc),
906
- bcc: parseAddresses(current.bcc),
907
- subject: current.subject,
908
- html: current.html,
909
- });
910
- lastSavedRef.current = savedKey;
911
- saveErrorRef.current = null;
912
- // A From switch may have replaced the draft while this was in flight - never resurrect it,
913
- // but do keep its superseded copy's version current so its pending delete still matches.
914
- setDraft((prev) => (prev?.uid === saved.uid ? saved : prev));
915
- const superseded = supersededDraftsRef.current.find((d) => d.uid === saved.uid);
916
- if (superseded) {
917
- superseded.version = saved.version;
918
- }
919
- setSaveStatus("saved");
920
- return { message: saved };
921
- } catch (err) {
922
- saveErrorRef.current = err instanceof ApiRequestError ? err.message : "the server couldn't be reached";
923
- setSaveStatus("error");
924
- return {};
925
- }
926
- })();
927
- saveInFlightRef.current = outcome.then((result) => result.message);
928
- return outcome;
929
- }
930
-
931
- /** Keeps the window open after its content couldn't be saved, offering Discard or Keep editing. */
932
- function showSaveFailedPrompt() {
933
- setClosePrompt({
934
- title: "Couldn't save this draft",
935
- message: `It couldn't be saved (${saveErrorRef.current!.replace(/\.$/, "")}). Keep editing and try again, or discard it.`,
936
- });
937
- }
938
-
939
- /**
940
- * Deletes `current` (this window's server draft) once any save already on the wire has landed, so the delete
941
- * carries the draft's current version. A delete rejected for a stale version (404/409: e.g. an attachment
942
- * upload bumped it) is retried once with the version the server holds - and a draft the server no longer has
943
- * counts as deleted. The retry only happens while the server copy is still an unsent draft in the same folder.
944
- * Rejects when the draft couldn't be deleted.
945
- */
946
- async function deleteDraft(current: Message): Promise<void> {
947
- const saved = await saveInFlightRef.current;
948
- const version = Math.max(current.version, saved?.uid === current.uid ? saved.version : 0);
949
- try {
950
- await deleteMessage(current.uid, version);
951
- } catch (err) {
952
- if (!(err instanceof ApiRequestError) || (err.status !== 404 && err.status !== 409)) {
953
- throw err;
954
- }
955
- const fresh = await getMessage(current.uid).catch((getErr: unknown) => {
956
- if (getErr instanceof ApiRequestError && getErr.status === 404) {
957
- return null;
958
- }
959
- throw getErr;
960
- });
961
- if (!fresh) {
962
- return;
963
- }
964
- // The version changed because another window sent or scheduled it - that message isn't ours to delete.
965
- if (!isStillDraft(fresh, current.folderUid)) {
966
- throw new ApiRequestError(NO_LONGER_A_DRAFT_MESSAGE, 409);
967
- }
968
- await deleteMessage(fresh.uid, fresh.version);
969
- }
970
- }
971
-
972
- /**
973
- * Discards the window's draft and closes it. With nothing the user would lose (a blank draft) it closes
974
- * straight away and deletes the draft in the background; otherwise it closes only once the draft is
975
- * deleted, and a delete that fails keeps the window open with the error.
976
- */
977
- async function discardNow() {
978
- finishedRef.current = true;
979
- pendingSaveRef.current = false;
980
- setClosePrompt(null);
981
- setDiscardError(null);
982
- const current = draft;
983
- if (!current || !hasUserContent) {
984
- if (current) {
985
- void deleteDraft(current).catch(() => undefined);
986
- }
987
- onClose();
988
- return;
989
- }
990
- setClosing(true);
991
- try {
992
- await deleteDraft(current);
993
- } catch (err) {
994
- finishedRef.current = false;
995
- setClosing(false);
996
- setDiscardError(`Couldn't discard this draft: ${err instanceof ApiRequestError ? err.message : "the server couldn't be reached."}`);
997
- return;
998
- }
999
- onClose();
1000
- }
1001
-
1002
- /** "Discard draft": confirms first whenever there's anything the user would lose. */
1003
- function handleDiscard() {
1004
- if (hasUserContent) {
1005
- setClosePrompt({ title: DISCARD_TITLE, message: "This permanently deletes this draft and everything you've written in it." });
1006
- } else {
1007
- void discardNow();
1008
- }
1009
- }
1010
-
1011
- /** "Close": keeps the draft (saving any unsaved edits first, and closing only once that save succeeded) -
1012
- * unless there's nothing in it worth keeping, or it can't be saved because it's really headed for encryption
1013
- * (asked for, or applied by the loaded policy): that needs the same confirmation as a discard. Whatever is
1014
- * *unknown* (settings loading or failed, a lookup pending) is not a reason to hold the window: it is saved like any
1015
- * other message. A failed save keeps the window open with Discard / Keep editing. */
1016
- async function handleClose() {
1017
- if (!hasUserContent) {
1018
- void discardNow();
1019
- return;
1020
- }
1021
- if (encryptionDecided) {
1022
- setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
1023
- return;
1024
- }
1025
- if (!draft || !contentReady) {
1026
- saveErrorRef.current = draftError ?? "the draft hasn't been created yet";
1027
- showSaveFailedPrompt();
1028
- return;
1029
- }
1030
- if (lastSavedRef.current !== `${draft.uid}:${contentKey}`) {
1031
- setClosing(true);
1032
- const outcome = await saveDraftNow();
1033
- setClosing(false);
1034
- if (outcome.skipped) {
1035
- // It became a message that mustn't be saved as plaintext while waiting on an earlier save.
1036
- setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
1037
- return;
1038
- }
1039
- if (!outcome.message) {
1040
- showSaveFailedPrompt();
1041
- return;
1042
- }
1043
- }
1044
- finishedRef.current = true;
1045
- onClose();
1046
- }
1047
-
1048
- // A plain read from keySession.ts's module-level session store, not React state - see that module's
1049
- // own doc comment. Cheap enough to read fresh on every render rather than caching in state.
1050
- const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
1051
- const hasEnrolledSigningKey = !!findActivePublicKey(mailbox?.keys ?? [], "sign");
1052
- const hasEnrolledEncryptionKey = !!findActivePublicKey(mailbox?.keys ?? [], "encrypt");
1053
- // This mailbox has a real signing/encryption key on file, but this session hasn't unlocked it yet -
1054
- // the sign/encrypt toggles below stay hidden until it has, so this is the only way to reach them
1055
- // without first stumbling into Mail generally (which no longer force-prompts on its own - see
1056
- // `MailShell`'s `blocking={false}`). `getUnlockedKeys()` is re-read as a plain module value above, so
1057
- // bumping `unlockRefresh` (after a successful unlock, or on any key-session event for this mailbox -
1058
- // see the `subscribeKeySession()` effect) is what makes this line (and the toggles it gates) reflect
1059
- // it on the next render.
1060
- const needsUnlockForCrypto = (hasEnrolledSigningKey || hasEnrolledEncryptionKey) && !unlockedKeys;
1061
-
1062
- // Keys locking (idle timeout, logout elsewhere, ...) or unlocking from another window must update the
1063
- // toggles/unlock affordance right away, not on whatever unrelated render happens next.
1064
- useEffect(() => subscribeKeySession((event) => {
1065
- if (event.mailboxUid === mailboxUid) {
1066
- setUnlockRefresh((n) => n + 1);
1067
- }
1068
- }), [mailboxUid]);
1069
-
1070
- useEffect(() => {
1071
- offeredCryptoRef.current = {
1072
- sign: offeredCryptoRef.current.sign || !!unlockedKeys?.signingPrivateKey,
1073
- encrypt: offeredCryptoRef.current.encrypt || !!unlockedKeys?.encryptionPrivateKey,
1074
- };
1075
- });
1076
-
1077
- // Autosave. "User content" is anything beyond what compose opened with (prefilled recipients/subject,
1078
- // seeded signature/quote) - an untouched window has nothing worth saving or confirming a discard of.
1079
- const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
1080
- const baselineKey = JSON.stringify([baselineTo ?? "", baselineCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
1081
- const hasUserContent = contentKey !== baselineKey || hasUploads;
1082
- // Whether this message is *really* headed for encryption - `encryptionRequirement.ts` decides, and it fails open: the message is plain (its
1083
- // draft saves, Close and Send work, nothing is shown) unless the user asked for encryption or the policy was loaded and applies it to every
1084
- // recipient (whose keys were all looked up). What has not loaded, or failed to, is not held against the message.
1085
- //
1086
- // Transitions, when a later load turns a plain message into an encrypted one (the policy arrives, a slow lookup answers, a recipient is
1087
- // added): (1) autosave stops at once (`autosaveSuppressed`); (2) a plaintext copy this window already saved is *replaced by an empty draft*
1088
- // (`scrubSavedDraft()` below) so the plaintext doesn't sit on the server; (3) Close asks first ("Encrypted messages aren't saved as drafts");
1089
- // (4) Send encrypts (or is refused, with the override, when it can't) - never silently plaintext. And back: when it stops being encrypted
1090
- // (a recipient removed) autosave simply resumes.
1091
- const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
1092
- const currentStatuses = currentAddresses.map((address) => recipientStatuses[addressCacheKey(address)]);
1093
- const hasEncryptionKey = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
1094
- const requirement = evaluateEncryptionRequirement({
1095
- encryptRequested,
1096
- policy: encryptionPolicy ?? undefined,
1097
- mailboxLoaded: !!mailbox,
1098
- hasEncryptionKey,
1099
- recipients: currentStatuses,
1100
- });
1101
- const encryptionDecided = requirement.required;
1102
- const autosaveSuppressed = encryptionDecided;
1103
- const latestRef = useRef({
1104
- draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
1105
- statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
1106
- });
1107
- latestRef.current = {
1108
- draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
1109
- statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
1110
- };
1111
-
1112
- /** A message that turned out to need encryption after a plaintext copy of it was saved: the saved copy is replaced by an empty draft (the same
1113
- * uid, so attachments and the folder are undisturbed), chained after any save still on the wire. Best effort - a failure is reported once. */
1114
- function scrubSavedDraft(target: Message) {
1115
- lastSavedRef.current = null;
1116
- setSaveStatus("idle");
1117
- const previous = saveInFlightRef.current;
1118
- const scrub = (async (): Promise<Message | undefined> => {
1119
- await previous;
1120
- try {
1121
- // Its answer (the new version) is what a later delete waits for through `saveInFlightRef`, like any save's.
1122
- return await assembleDraft(target.uid, { to: [], cc: [], bcc: [], subject: "", html: "" });
1123
- } catch {
1124
- notify({
1125
- id: `scrub-draft:${target.uid}`,
1126
- kind: "warning",
1127
- title: "An earlier draft copy is still saved",
1128
- message: "This message needs encryption, but the copy saved before that was known couldn't be replaced. Discarding the draft removes it.",
1129
- });
1130
- return undefined;
1131
- }
1132
- })();
1133
- saveInFlightRef.current = scrub;
1134
- }
1135
- // Only a message that was really saved (`lastSavedRef` names its draft) has a plaintext copy to replace.
1136
- useEffect(() => {
1137
- if (encryptionDecided && draft && lastSavedRef.current?.startsWith(`${draft.uid}:`)) {
1138
- scrubSavedDraft(draft);
1139
- }
1140
- }, [encryptionDecided, draft?.uid]);
1141
-
1142
- // Recipients a reply could only work out after this window opened (Reply All: whoever the original's headers name)
1143
- // replace the ones it opened with - but only in a field the user hasn't touched, never over what they typed.
1144
- useEffect(() => {
1145
- if (!late) {
1146
- return;
1147
- }
1148
- const current = latestRef.current;
1149
- if (late.to !== undefined && current.to === (initialTo ?? "")) {
1150
- setTo(late.to);
1151
- }
1152
- if (late.cc !== undefined && current.cc === (initialCc ?? "")) {
1153
- setCc(late.cc);
1154
- setShowCcBcc((shown) => shown || late.cc !== "");
1155
- }
1156
- }, [late]);
1157
-
1158
- useEffect(() => {
1159
- const due =
1160
- !!draft &&
1161
- contentReady &&
1162
- !autosaveSuppressed &&
1163
- hasUserContent &&
1164
- lastSavedRef.current !== `${draft.uid}:${contentKey}`;
1165
- pendingSaveRef.current = due;
1166
- if (!due) {
1167
- return;
1168
- }
1169
- const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
1170
- return () => clearTimeout(timer);
1171
- }, [draft, contentReady, autosaveSuppressed, hasUserContent, contentKey]);
1172
-
1173
- // Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
1174
- // right away rather than losing it.
1175
- useEffect(
1176
- () => () => {
1177
- if (pendingSaveRef.current && !finishedRef.current) {
1178
- void saveDraftNow();
1179
- }
1180
- },
1181
- [],
1182
- );
1183
-
1184
- /** Saves an edit still waiting on the debounce now, and resolves once that save - or one already on the
1185
- * wire - has settled, to whether the window's content is saved. A save that failed keeps the window open
1186
- * with Discard / Keep editing. A send clears `pendingSaveRef` before it starts, so this never saves mid-send. */
1187
- async function flushPendingSave(): Promise<boolean> {
1188
- if (pendingSaveRef.current && !finishedRef.current) {
1189
- const outcome = await saveDraftNow();
1190
- if (outcome.skipped) {
1191
- // Not saved, but not a failure either: it may be headed for encryption (or was sent/discarded meanwhile).
1192
- return finishedRef.current;
1193
- }
1194
- if (!outcome.message) {
1195
- showSaveFailedPrompt();
1196
- }
1197
- return !!outcome.message;
1198
- }
1199
- await saveInFlightRef.current;
1200
- const latest = latestRef.current;
1201
- if (finishedRef.current || !latest.hasUserContent || (!!latest.draft && lastSavedRef.current === `${latest.draft.uid}:${latest.contentKey}`)) {
1202
- return true;
1203
- }
1204
- // Unsaved because the last save failed (rather than, say, an encrypted message never being saved).
1205
- if (saveErrorRef.current) {
1206
- showSaveFailedPrompt();
1207
- }
1208
- return false;
1209
- }
1210
-
1211
- // Sign Out (AppShell) waits for this before ending the session.
1212
- useEffect(() => registerComposeFlush(flushPendingSave), []);
1213
-
1214
- // Leaving the page (reload, closing the tab, following a link) with anything not saved: start a save that's
1215
- // waiting on the debounce straight away and ask the browser to confirm, so the last couple of seconds of
1216
- // typing (or content that can't be saved as a draft, or failed to) aren't lost silently. Never while signing
1217
- // out - that navigation must not be cancellable.
1218
- useEffect(() => {
1219
- function handleBeforeUnload(event: BeforeUnloadEvent) {
1220
- if (finishedRef.current || isSigningOut()) {
1221
- return;
1222
- }
1223
- const latest = latestRef.current;
1224
- const unsaved =
1225
- pendingSaveRef.current ||
1226
- latest.saveStatus === "saving" ||
1227
- (latest.hasUserContent && lastSavedRef.current !== `${latest.draft?.uid}:${latest.contentKey}`);
1228
- if (!unsaved) {
1229
- return;
1230
- }
1231
- void flushPendingSave();
1232
- event.preventDefault();
1233
- event.returnValue = "";
1234
- }
1235
- window.addEventListener("beforeunload", handleBeforeUnload);
1236
- return () => window.removeEventListener("beforeunload", handleBeforeUnload);
1237
- }, []);
1238
-
1239
- // Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
1240
- // with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
1241
- // window must not move the caret there again.
1242
- const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending || !!resume;
1243
- const fieldsFocusedRef = useRef(false);
1244
- const bodyFocusedRef = useRef(false);
1245
- const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
1246
- const focusSubjectOnMount = !isReplyOrForward && !!initialTo && !fieldsFocusedRef.current;
1247
- const focusBodyOnMount = isReplyOrForward && !bodyFocusedRef.current;
1248
-
1249
- // The editor serializes the seeded body its own way (see `RichTextEditor`'s `onInitialized`). While the body is
1250
- // still exactly what compose seeded, that serialization becomes the baseline - so an untouched reply or signature
1251
- // isn't mistaken for an edit (and autosaved) just because the editor was focused or clicked.
1252
- /** The quote has been added: unless the reader has typed something already, that is still exactly what compose seeded. */
1253
- function handleQuoteAppended(normalized: string) {
1254
- const latest = latestRef.current;
1255
- if (latest.html === latest.seededHtml) {
1256
- setHtml(normalized);
1257
- setSeededHtml(normalized);
1258
- }
1259
- }
1260
- function handleEditorInitialized(normalized: string) {
1261
- markComposePhase(id, "editor");
1262
- const latest = latestRef.current;
1263
- if (latest.html === latest.seededHtml && normalized !== latest.html) {
1264
- setHtml(normalized);
1265
- setSeededHtml(normalized);
1266
- }
1267
- }
1268
- useEffect(() => {
1269
- if (!minimized) {
1270
- fieldsFocusedRef.current = true;
1271
- bodyFocusedRef.current ||= contentReady;
1272
- }
1273
- }, [minimized, contentReady]);
1274
-
1275
- // Keyboard shortcuts, active while the focus is inside this window (`container`; the scope attribute on its root is what tells the
1276
- // dispatcher the focus is in a compose window at all). They do what the Send and Close buttons and autosave do, under the same conditions -
1277
- // a shortcut pressed while its button would be disabled is consumed and does nothing.
1278
- // Send never waits for the encryption settings to load (fail open, see `encryptionRequirement.ts`); it only needs the draft to exist. Pressed
1279
- // while an earlier press is still being handled it does nothing (`submit()` guards that itself).
1280
- const canSend = !!draft && !closing && !waitingForUploads;
1281
- useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
1282
- useShortcut(
1283
- SHORTCUTS.compose.saveDraft,
1284
- () => {
1285
- // Autosave's own conditions: never a draft that must not be stored as plaintext.
1286
- if (draft && contentReady && !closing && !autosaveSuppressed && hasUserContent) {
1287
- void saveDraftNow();
1288
- }
1289
- },
1290
- { container: windowRef },
1291
- );
1292
- useShortcut(
1293
- SHORTCUTS.compose.close,
1294
- () => {
1295
- if (!closing) {
1296
- void handleClose();
1297
- }
1298
- },
1299
- { container: windowRef },
1300
- );
1301
- useShortcut(SHORTCUTS.compose.create, () => openCompose({ mailboxUid }), { container: windowRef });
1302
-
1303
- const title = subject.trim() || "New Message";
1304
- const titleId = `compose-title-${id}`;
1305
-
1306
- const discardModal = (
1307
- <Modal open={closePrompt !== null} onClose={() => setClosePrompt(null)} title={closePrompt?.title ?? DISCARD_TITLE}>
1308
- <p className="text-sm text-text-muted mb-4">{closePrompt?.message}</p>
1309
- <div className="flex gap-3">
1310
- <Button type="button" onClick={() => void discardNow()} disabled={closing} className="!w-auto">
1311
- Discard
1312
- </Button>
1313
- <Button type="button" variant="secondary" onClick={() => setClosePrompt(null)} className="!w-auto">
1314
- Keep editing
1315
- </Button>
1316
- </div>
1317
- </Modal>
1318
- );
1319
-
1320
- if (minimized) {
1321
- return (
1322
- <>
1323
- <div role="dialog" aria-label={title} className="w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal">
1324
- <div
1325
- className="h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer"
1326
- onClick={onToggleMinimize}
1327
- >
1328
- <span className="text-sm font-medium truncate">{title}</span>
1329
- <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
1330
- <HeaderButton label="Restore" onClick={onToggleMinimize} icon={HiOutlineArrowsPointingOut} />
1331
- <HeaderButton label="Discard draft" onClick={handleDiscard} icon={HiOutlineXMark} disabled={closing} />
1332
- </div>
1333
- </div>
1334
- </div>
1335
- {discardModal}
1336
- </>
1337
- );
1338
- }
1339
-
1340
- return (
1341
- <>
1342
- <div
1343
- ref={windowRef}
1344
- role="dialog"
1345
- data-shortcut-scope="compose"
1346
- aria-labelledby={titleId}
1347
- style={!isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
1348
- className={[
1349
- "shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1350
- // `fixed` and `relative` may not both be on the element: Tailwind emits `.relative` after `.fixed`, so it wins and the
1351
- // sheet sat in the flow of the little fixed container the windows are stacked in instead of covering the screen.
1352
- isMobile
1353
- ? "fixed inset-0 w-full h-full rounded-none border-0"
1354
- : ["relative border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1355
- ].join(" ")}
1356
- >
1357
- {!isMobile && (
1358
- <>
1359
- <div
1360
- onPointerDown={handleResizeStart("top")}
1361
- aria-hidden="true"
1362
- className="absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20"
1363
- />
1364
- <div
1365
- onPointerDown={handleResizeStart("left")}
1366
- aria-hidden="true"
1367
- className="absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20"
1368
- />
1369
- <div
1370
- onPointerDown={handleResizeStart("corner")}
1371
- role="separator"
1372
- aria-label="Resize"
1373
- className="absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30"
1374
- />
1375
- </>
1376
- )}
1377
-
1378
- <div
1379
- className="h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer"
1380
- onClick={onToggleMinimize}
1381
- >
1382
- <span id={titleId} className="text-sm font-medium truncate">
1383
- {title}
1384
- </span>
1385
- <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
1386
- <HeaderButton label="Minimize" onClick={onToggleMinimize} icon={HiOutlineMinus} />
1387
- {!isMobile && (
1388
- <HeaderButton
1389
- label={expanded ? "Collapse" : "Expand"}
1390
- onClick={toggleExpanded}
1391
- icon={expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut}
1392
- />
1393
- )}
1394
- <HeaderButton label="Close" onClick={() => void handleClose()} icon={HiOutlineXMark} disabled={closing} shortcut={SHORTCUTS.compose.close} />
1395
- </div>
1396
- </div>
1397
-
1398
- <div className="flex-1 min-h-0 flex flex-col">
1399
- {(folderError || draftError || sendError || attachError || discardError) && (
1400
- // Scrolls inside the window when it is tall, instead of pushing the editor and the Send button out of the window's
1401
- // fixed height, where they can't be reached.
1402
- <div className="px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto">
1403
- {folderError && <Alert>{folderError}</Alert>}
1404
- {draftError && <Alert>{draftError}</Alert>}
1405
- {sendError && <Alert>{sendError}</Alert>}
1406
- {attachError && <Alert>{attachError}</Alert>}
1407
- {discardError && <Alert>{discardError}</Alert>}
1408
- </div>
1409
- )}
1410
-
1411
- {securityBlock && (
1412
- <div className="px-3 pt-2">
1413
- <Alert>
1414
- <p className="mb-2">{securityBlock.message}</p>
1415
- <Button type="button" onClick={() => void submit(true, blockedScheduleIso)}>
1416
- {securityBlock.overrideLabel}
1417
- </Button>
1418
- </Alert>
1419
- </div>
1420
- )}
1421
-
1422
- {fromOptions.length > 1 && (
1423
- <div className={FIELD_ROW}>
1424
- <label htmlFor={`compose-from-${id}`} className="text-xs text-text-muted shrink-0">
1425
- From
1426
- </label>
1427
- <select
1428
- id={`compose-from-${id}`}
1429
- className={`${FIELD_INPUT} disabled:opacity-55`}
1430
- // Two or more options only exist once the mailboxes have loaded, and the same answer that lets them list
1431
- // (one that isn't known to be view-only) is what picks the default sender, so a sender is always chosen by now.
1432
- value={mailboxUid}
1433
- disabled={hasUploads}
1434
- title={hasUploads ? "The sender can't be changed after adding attachments or images." : undefined}
1435
- onChange={(e) => handleFromChange(e.target.value)}
1436
- >
1437
- {fromOptions.map((mb) => (
1438
- <option key={mb.uid} value={mb.uid}>
1439
- {mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress}
1440
- {mb.ownerUserUid ? "" : " (shared)"}
1441
- </option>
1442
- ))}
1443
- </select>
1444
- </div>
1445
- )}
1446
-
1447
- <div className={FIELD_ROW}>
1448
- <label htmlFor={`compose-to-${id}`} className="text-xs text-text-muted shrink-0">
1449
- To
1450
- </label>
1451
- <RecipientInput
1452
- id={`compose-to-${id}`}
1453
- label="To"
1454
- value={to}
1455
- mailboxUid={mailboxUid}
1456
- onChange={setTo}
1457
- onBlur={(value) => checkRecipientDiscovery(parseAddresses(value))}
1458
- onCommit={(value) => checkRecipientDiscovery(parseAddresses(value))}
1459
- autoFocus={focusToOnMount}
1460
- />
1461
- {!showCcBcc && (
1462
- <button
1463
- type="button"
1464
- onClick={() => setShowCcBcc(true)}
1465
- className="text-xs text-text-muted hover:text-text shrink-0"
1466
- >
1467
- Cc Bcc
1468
- </button>
1469
- )}
1470
- </div>
1471
-
1472
- {showCcBcc && (
1473
- <>
1474
- <div className={FIELD_ROW}>
1475
- <label htmlFor={`compose-cc-${id}`} className="text-xs text-text-muted shrink-0">
1476
- Cc
1477
- </label>
1478
- <RecipientInput
1479
- id={`compose-cc-${id}`}
1480
- label="Cc"
1481
- value={cc}
1482
- mailboxUid={mailboxUid}
1483
- onChange={setCc}
1484
- onBlur={(value) => checkRecipientDiscovery(parseAddresses(value))}
1485
- onCommit={(value) => checkRecipientDiscovery(parseAddresses(value))}
1486
- />
1487
- </div>
1488
- <div className={FIELD_ROW}>
1489
- <label htmlFor={`compose-bcc-${id}`} className="text-xs text-text-muted shrink-0">
1490
- Bcc
1491
- </label>
1492
- <RecipientInput
1493
- id={`compose-bcc-${id}`}
1494
- label="Bcc"
1495
- value={bcc}
1496
- mailboxUid={mailboxUid}
1497
- onChange={setBcc}
1498
- onBlur={(value) => checkRecipientDiscovery(parseAddresses(value))}
1499
- onCommit={(value) => checkRecipientDiscovery(parseAddresses(value))}
1500
- />
1501
- </div>
1502
- </>
1503
- )}
1504
-
1505
- {(() => {
1506
- // De-duplicated by the same normalized key `recipientStatuses` itself is keyed by - two
1507
- // fields (or one field twice) naming the same mailbox in different casing now resolve to
1508
- // one shared status object (see `addressCacheKey()`'s own doc comment), so without this
1509
- // the list below would render - and React would warn about - two `<li key=...>`s sharing
1510
- // the exact same key.
1511
- const seenKeys = new Set<string>();
1512
- const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
1513
- .filter((r) => {
1514
- const key = addressCacheKey(r.address);
1515
- if (seenKeys.has(key)) {
1516
- return false;
1517
- }
1518
- seenKeys.add(key);
1519
- return true;
1520
- })
1521
- .map((r) => recipientStatuses[addressCacheKey(r.address)])
1522
- .filter((status): status is RecipientEncryptionStatus => !!status);
1523
- if (knownRecipients.length === 0) {
1524
- return null;
1525
- }
1526
- return (
1527
- <ul className="flex flex-wrap gap-1.5 px-3 pt-1.5" aria-label="Recipient encryption availability">
1528
- {knownRecipients.map((status) => (
1529
- <li
1530
- key={status.address}
1531
- className={`text-xs font-medium py-0.5 px-2 rounded-pill ${
1532
- status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"
1533
- }`}
1534
- >
1535
- {status.address} {status.canEncrypt ? "supports encryption" : "no encryption key found"}
1536
- </li>
1537
- ))}
1538
- </ul>
1539
- );
1540
- })()}
1541
-
1542
- <div className={FIELD_ROW}>
1543
- <input
1544
- aria-label="Subject"
1545
- type="text"
1546
- placeholder="Subject"
1547
- className={FIELD_INPUT}
1548
- value={subject}
1549
- autoFocus={focusSubjectOnMount}
1550
- onChange={(e) => setSubject(e.target.value)}
1551
- />
1552
- </div>
1553
-
1554
- <div className="flex-1 min-h-0 p-2">
1555
- {contentReady && (
1556
- <RichTextEditor
1557
- appendHtml={lateQuoteHtml}
1558
- onAppended={handleQuoteAppended}
1559
- value={html}
1560
- onChange={setHtml}
1561
- fill
1562
- onUploadImage={handleUploadImage}
1563
- autoFocusStart={focusBodyOnMount}
1564
- onInitialized={handleEditorInitialized}
1565
- />
1566
- )}
1567
- </div>
1568
-
1569
- {quotePending && (
1570
- <p role="status" className="px-3 pb-1 text-xs text-text-muted">
1571
- Loading the original message&hellip;
1572
- </p>
1573
- )}
1574
-
1575
- {attachments.length > 0 && (
1576
- <ul className="flex flex-wrap gap-2 px-3 pb-2">
1577
- {attachments.map((attachment) => (
1578
- <li key={attachment.uid} className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted">
1579
- {attachment.filename}
1580
- </li>
1581
- ))}
1582
- </ul>
1583
- )}
1584
-
1585
- <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
1586
- <input type="checkbox" checked={requestReceipt} onChange={(e) => setRequestReceipt(e.target.checked)} />
1587
- Request a read receipt
1588
- </label>
1589
-
1590
- {needsUnlockForCrypto && (
1591
- <div className="px-3 pb-1">
1592
- <button
1593
- type="button"
1594
- onClick={handleUnlockForCrypto}
1595
- className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
1596
- >
1597
- <HiOutlineLockClosed size={12} aria-hidden="true" />
1598
- Unlock to sign or encrypt this message
1599
- </button>
1600
- </div>
1601
- )}
1602
-
1603
- {/* Only rendered once this session actually has a usable signing/encryption key - see
1604
- `sendJob.ts`'s signing path for why signing rarely shows today (no signing
1605
- certificate exists until RFC 8823 ACME enrollment lands). */}
1606
- {unlockedKeys?.signingPrivateKey && (
1607
- <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
1608
- <input type="checkbox" checked={signEnabled} onChange={(e) => setSignEnabled(e.target.checked)} />
1609
- Digitally sign this message
1610
- </label>
1611
- )}
1612
- {unlockedKeys?.encryptionPrivateKey && (
1613
- <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
1614
- <input type="checkbox" checked={encryptRequested} onChange={(e) => setEncryptRequested(e.target.checked)} />
1615
- Encrypt this message
1616
- </label>
1617
- )}
1618
-
1619
- <div className="shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border">
1620
- <div className="flex items-center rounded-pill bg-primary text-white overflow-hidden">
1621
- <button
1622
- type="button"
1623
- onClick={() => void submit(false)}
1624
- title={withHint("Send", SHORTCUTS.compose.send, keyEnv)}
1625
- aria-keyshortcuts={ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv)}
1626
- disabled={!draft || closing || waitingForUploads}
1627
- className="py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
1628
- >
1629
- Send
1630
- </button>
1631
- <button
1632
- ref={scheduleButtonRef}
1633
- type="button"
1634
- aria-label="Send later"
1635
- aria-haspopup="true"
1636
- aria-expanded={schedulePickerOpen}
1637
- disabled={!draft || closing || waitingForUploads}
1638
- onClick={() => setSchedulePickerOpen((o) => !o)}
1639
- className="py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
1640
- >
1641
- <HiOutlineChevronDown size={14} />
1642
- </button>
1643
- </div>
1644
- {schedulePickerOpen && (
1645
- <ScheduleSendPicker
1646
- anchorRef={scheduleButtonRef}
1647
- onClose={() => setSchedulePickerOpen(false)}
1648
- onSchedule={handleScheduleSend}
1649
- />
1650
- )}
1651
-
1652
- <label
1653
- aria-label="Attach files"
1654
- title="Attach files"
1655
- className="w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed"
1656
- >
1657
- <HiOutlinePaperClip size={18} />
1658
- <input type="file" multiple disabled={!draft} onChange={handleFilesSelected} className="sr-only" />
1659
- </label>
1660
-
1661
- <span role="status" aria-live="polite" className="ml-auto text-xs text-text-muted">
1662
- {waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus]}
1663
- </span>
1664
-
1665
- <button
1666
- type="button"
1667
- aria-label="Discard draft"
1668
- title="Discard draft"
1669
- onClick={handleDiscard}
1670
- disabled={closing}
1671
- className="w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed"
1672
- >
1673
- <HiOutlineTrash size={18} />
1674
- </button>
1675
- </div>
1676
- </div>
1677
- </div>
1678
- {discardModal}
1679
- </>
1680
- );
1681
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ChangeEvent, useEffect, useRef, useState } from "react";
6
+ import {
7
+ HiOutlineArrowsPointingIn,
8
+ HiOutlineArrowsPointingOut,
9
+ HiOutlineChevronDown,
10
+ HiOutlineLockClosed,
11
+ HiOutlineMinus,
12
+ HiOutlinePaperClip,
13
+ HiOutlineTrash,
14
+ HiOutlineXMark,
15
+ } from "react-icons/hi2";
16
+ import { ApiRequestError } from "../../../../../lib/util/api.js";
17
+ import {
18
+ Attachment,
19
+ ComposeRecipientInput,
20
+ Mailbox,
21
+ Message,
22
+ assembleDraft,
23
+ attachmentContentUrl,
24
+ createDraft,
25
+ deleteMessage,
26
+ getMailbox,
27
+ getMessage,
28
+ listFolders,
29
+ listMailboxes,
30
+ uploadAttachment,
31
+ } from "../../../../../lib/mail/mailApi.js";
32
+ import { listMailSignatures } from "../../../../../lib/mail/mailSignaturesApi.js";
33
+ import { buildComposeBodyHtml } from "../../../../../lib/mail/compose/composeQuoting.js";
34
+ import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
35
+ import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
36
+ import { resolveRecipientEncryption, RecipientEncryptionStatus } from "../../../../../lib/crypto/composeSecurity.js";
37
+ import { getUnlockedKeys, subscribeKeySession } from "../../../../../lib/crypto/keySession.js";
38
+ import { EncryptionPolicy, findActivePublicKey, getEncryptionPolicy, lookupKeys } from "../../../../../lib/crypto/keyvaultApi.js";
39
+ import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
40
+ import useIsMobile from "../../../../../lib/util/useIsMobile.js";
41
+ import { notify } from "../../../notifications/store.js";
42
+ import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
43
+ import { decideSend, SendBlock } from "../../../mail/outbox/sendDecision.js";
44
+ import { SendRequest, sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
45
+ import { useCompose, type ComposeSession } from "./ComposeContext.js";
46
+ import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
47
+ import { SHORTCUTS, ShortcutDef } from "../../../keyboard/keymap.js";
48
+ import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
49
+ import { useShortcut } from "../../../keyboard/useShortcut.js";
50
+ import { isSigningOut, registerComposeFlush } from "./composeFlushRegistry.js";
51
+ import { markComposePhase } from "./composePerf.js";
52
+ import RecipientInput from "./RecipientInput.js";
53
+ import { parseRecipientList } from "./recipients.js";
54
+ import RichTextEditor from "./RichTextEditor.js";
55
+ import ScheduleSendPicker from "./ScheduleSendPicker.js";
56
+ import Alert from "../../../../../lib/components/feedback/Alert.js";
57
+ import Button from "../../../../../lib/components/buttons/Button.js";
58
+ import Modal from "../../../../../lib/components/overlays/Modal.js";
59
+
60
+ export interface ComposeWindowProps {
61
+ session: ComposeSession;
62
+ onClose: () => void;
63
+ onToggleMinimize: () => void;
64
+ /** The signed-in user - identifies their own ("primary") mailbox, the default From when the session
65
+ * doesn't name a mailbox. */
66
+ userUid?: string;
67
+ /** The caller holds a trusted (admin) role - every mailbox is writable, so no per-mailbox access checks. */
68
+ trusted?: boolean;
69
+ /** How long (ms) after the last edit the draft is autosaved. Defaults to `DEFAULT_AUTOSAVE_DELAY_MS`;
70
+ * only overridden by tests. */
71
+ autosaveDelayMs?: number;
72
+ /** The longest (ms) an unsaved edit waits before the draft is saved even though typing hasn't paused - so a
73
+ * message written without a 2-second break is still saved regularly. Defaults to `DEFAULT_AUTOSAVE_MAX_WAIT_MS`
74
+ * and is never shorter than `autosaveDelayMs`; only overridden by tests. */
75
+ autosaveMaxWaitMs?: number;
76
+ /** Backoff (ms) between automatic retries of a failed mailbox/encryption-policy load, one entry per retry.
77
+ * Defaults to `DEFAULT_CRYPTO_RETRY_DELAYS_MS`; only overridden by tests. */
78
+ cryptoRetryDelaysMs?: number[];
79
+ }
80
+
81
+ /** Debounce between the last edit and the draft autosave. */
82
+ export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
83
+ /** The longest an edit stays unsaved while the user keeps typing (the debounce above restarts on every keystroke, so it alone never fires then). */
84
+ export const DEFAULT_AUTOSAVE_MAX_WAIT_MS = 30_000;
85
+
86
+ /** Automatic, silent retries of a failed mailbox/encryption-policy (or recipient key) load, one entry per retry - a flapping gateway is waited out
87
+ * in the background, without a word to the user. */
88
+ export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000, 60000];
89
+
90
+ /** Thrown by `deleteDraft()` when the server copy is no longer this window's draft (sent or scheduled elsewhere). */
91
+ const NO_LONGER_A_DRAFT_MESSAGE = "This message was already sent or scheduled from another window, so it wasn't deleted.";
92
+
93
+ /** Whether `fresh` (a re-read of a draft) is still an unsent draft in `folderUid`: never delete a message another
94
+ * window has since sent, scheduled, or started sending. */
95
+ function isStillDraft(fresh: Message, folderUid: string): boolean {
96
+ return fresh.folderUid === folderUid && !fresh.scheduledSendTime && !fresh.scheduledSendLeaseExpiresAt && !fresh.scheduledSendRelayedAt;
97
+ }
98
+
99
+ const SAVE_STATUS_LABEL = { idle: "", saving: "Saving…", saved: "Draft saved", error: "Couldn't save draft" } as const;
100
+
101
+ const DISCARD_TITLE = "Discard this draft?";
102
+ /** Shown only for a message that is actually headed for encryption (asked for, or applied by the loaded policy) - see `encryptionRequirement.ts`. */
103
+ const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so closing this window discards what you've written.";
104
+
105
+ /** The confirmation shown before a Close/Discard throws content away (or when a Close couldn't save it). */
106
+ interface ClosePrompt {
107
+ title: string;
108
+ message: string;
109
+ }
110
+
111
+ const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
112
+ const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
113
+
114
+ const MIN_WIDTH = 320;
115
+ const MIN_HEIGHT = 320;
116
+ // Kept well clear of the viewport edge, not flush against it — this is a resize *ceiling* (the drag
117
+ // handles below clamp to it), not the "expanded" preset's own size, which stays a plain `85vh`
118
+ // Tailwind class precisely so it never needs `window.innerHeight` at all: that'd have to be read at
119
+ // module-evaluation time to live alongside these other constants, and `window` doesn't exist yet
120
+ // during this app's SSR pass (see `ReactRoute`) — referencing it here would crash every page render,
121
+ // not just this component's.
122
+ const VIEWPORT_MARGIN = 24;
123
+
124
+ interface Size {
125
+ width: number;
126
+ height: number;
127
+ }
128
+
129
+ type ResizeEdge = "left" | "top" | "corner";
130
+
131
+ /** A To/Cc/Bcc field's recipients - see `recipients.ts` for the format. */
132
+ function parseAddresses(value: string): ComposeRecipientInput[] {
133
+ return parseRecipientList(value);
134
+ }
135
+
136
+ /**
137
+ * Normalizes an address into the key `recipientStatuses`/the in-flight and failure trackers below use -
138
+ * matching `classifyRecipientTier()`'s own lowercase-domain comparison (`@rapidmx/react-shared`'s
139
+ * `composeSecurity.ts`), but applied to the whole address rather than just the domain. Without this, the
140
+ * same mailbox typed with different casing across To/Cc/Bcc (plausible via autocomplete vs. a pasted
141
+ * signature block) is tracked as two independent recipients: if one lookup lags or fails while the other
142
+ * succeeds, `decideMessageEncryption()`'s all-or-nothing check sees a spurious unresolved recipient and
143
+ * denies auto-encryption even though every *real* recipient resolved fine. Only ever used as an internal
144
+ * cache key - `ComposeRecipientInput.address` itself (what is actually sent as the recipient) is untouched.
145
+ */
146
+ function addressCacheKey(address: string): string {
147
+ return address.trim().toLowerCase();
148
+ }
149
+
150
+ function HeaderButton({
151
+ label,
152
+ onClick,
153
+ icon: Icon,
154
+ disabled,
155
+ shortcut,
156
+ }: {
157
+ label: string;
158
+ onClick: () => void;
159
+ icon: React.ComponentType<{ size?: number }>;
160
+ disabled?: boolean;
161
+ /** The keyboard shortcut that does the same, named in the tooltip and `aria-keyshortcuts`. */
162
+ shortcut?: ShortcutDef;
163
+ }) {
164
+ const env = useKeyEnvironment();
165
+ return (
166
+ <button
167
+ type="button"
168
+ aria-label={label}
169
+ title={shortcut ? withHint(label, shortcut, env) : label}
170
+ aria-keyshortcuts={shortcut ? ariaKeyShortcuts(shortcut, env) : undefined}
171
+ onClick={onClick}
172
+ disabled={disabled}
173
+ className="w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
174
+ >
175
+ <Icon size={14} />
176
+ </button>
177
+ );
178
+ }
179
+
180
+ /**
181
+ * A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
182
+ * navigating to a dedicated page (see `ComposeContext.tsx`'s doc comment for why: a full-page compose
183
+ * form meant losing your place in the inbox/calendar/contacts view behind it, and every navigation to
184
+ * `/compose` re-ran this whole app's full mailbox/folder resolution waterfall from scratch). Owns its
185
+ * own draft lifecycle end to end (unlike the old page, which read the Drafts folder from `MailShell`'s
186
+ * context — this window can be opened from apps that never mount `MailShell` at all, e.g. Contacts'
187
+ * "Email" action, so it resolves its own Drafts folder from just a `mailboxUid`).
188
+ *
189
+ * Below the `md` breakpoint, a non-minimized session instead renders as a full-screen sheet (there's no
190
+ * room for a floating window, and the click-drag resize handles below become meaningless full-screen) —
191
+ * see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
192
+ * once.
193
+ */
194
+ export default function ComposeWindow({
195
+ session,
196
+ onClose,
197
+ onToggleMinimize,
198
+ userUid,
199
+ trusted,
200
+ autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS,
201
+ autosaveMaxWaitMs = DEFAULT_AUTOSAVE_MAX_WAIT_MS,
202
+ cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS,
203
+ }: ComposeWindowProps) {
204
+ const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late, resume } = session;
205
+ // What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
206
+ // only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
207
+ const baselineTo = late?.to ?? initialTo;
208
+ const baselineCc = late?.cc ?? initialCc;
209
+ useEffect(() => {
210
+ markComposePhase(id, "shell");
211
+ markComposePhase(id, "chunk");
212
+ }, []);
213
+ // The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
214
+ // compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
215
+ // Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
216
+ const [fromMailboxUid, setFromMailboxUid] = useState<string | undefined>(session.mailboxUid);
217
+ const mailboxUid = fromMailboxUid;
218
+ const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
219
+ // Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
220
+ // Drafts folder - so once anything has been uploaded the sender can no longer be switched.
221
+ const [hasUploads, setHasUploads] = useState(!!resume && resume.attachments.length > 0);
222
+ const isMobile = useIsMobile();
223
+ const { requestUnlock } = useUnlockPrompt();
224
+ // Resolved once by `ComposeProvider` (see `ComposeContext.tsx`'s own doc comment on `ComposeContextValue.client`)
225
+ // rather than calling `useApiClient()` again here - every REST call below (including `startSend()`'s own,
226
+ // see `sendJob.ts`) passes it through as the trailing `client` argument, `undefined` (the default global
227
+ // cookie-based fetch) for every existing caller.
228
+ const { openCompose, client } = useCompose();
229
+ const keyEnv = useKeyEnvironment();
230
+ // Bumped after a successful on-demand unlock to force a re-render - `getUnlockedKeys()` below is a
231
+ // plain read from a module-level store, not React state, so nothing else would pick up the change.
232
+ const [, setUnlockRefresh] = useState(0);
233
+
234
+ const windowRef = useRef<HTMLDivElement>(null);
235
+ const [draftsFolderUid, setDraftsFolderUid] = useState<string | undefined>();
236
+ const [folderError, setFolderError] = useState<string | null>(null);
237
+ // A window re-opened around a message that was already composed (a failed send's "Open draft") continues that server draft and starts with
238
+ // every field as it was typed; its body is not seeded from a signature or a quote.
239
+ const [draft, setDraft] = useState<Message | null>(resume?.draft ?? null);
240
+ // Drafts replaced by a From switch, awaiting deletion until their replacement has actually been created.
241
+ const supersededDraftsRef = useRef<Message[]>([]);
242
+ const [draftError, setDraftError] = useState<string | null>(null);
243
+ const [expanded, setExpanded] = useState(false);
244
+ const [manualSize, setManualSize] = useState<Size | null>(null);
245
+ const [to, setTo] = useState(resume?.to ?? initialTo ?? "");
246
+ const [cc, setCc] = useState(resume?.cc ?? initialCc ?? "");
247
+ const [bcc, setBcc] = useState(resume?.bcc ?? "");
248
+ const [showCcBcc, setShowCcBcc] = useState(!!(resume ? resume.cc || resume.bcc : initialCc));
249
+ const [subject, setSubject] = useState(resume?.subject ?? initialSubject ?? "");
250
+ const [html, setHtml] = useState(resume?.html ?? "");
251
+ const [contentReady, setContentReady] = useState(!!resume);
252
+ const [attachments, setAttachments] = useState<Attachment[]>(resume?.attachments ?? []);
253
+ const [attachError, setAttachError] = useState<string | null>(null);
254
+ // A problem with the message as composed (no recipient, an attachment that can't be signed): shown here, and the window stays open. What
255
+ // goes wrong after Send was accepted is a pop-up instead (see `sendJob.ts`).
256
+ const [sendError, setSendError] = useState<string | null>(null);
257
+ // Send was pressed while an attachment was still uploading: the window waits (and says so) rather than send without it.
258
+ const [waitingForUploads, setWaitingForUploads] = useState(false);
259
+ const [requestReceipt, setRequestReceipt] = useState(!!resume?.requestReceipt);
260
+ const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
261
+ const scheduleButtonRef = useRef<HTMLButtonElement>(null);
262
+ const [mailbox, setMailbox] = useState<Mailbox | null>(null);
263
+ const [encryptionPolicy, setEncryptionPolicy] = useState<EncryptionPolicy | null>(null);
264
+ // Defaults off when replying to a detected mailing list (spec's own "Mailing lists" note under
265
+ // Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
266
+ // `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
267
+ // still turn it back on; this only changes the default.
268
+ const [signEnabled, setSignEnabled] = useState(resume ? resume.signEnabled : !suppressSigning);
269
+ // A reply to or forward of an encrypted message starts (and, after a From switch, restarts) with encryption
270
+ // requested - see `OpenComposeInput.encrypt`.
271
+ const [encryptRequested, setEncryptRequested] = useState(resume ? resume.encryptRequested : !!initialEncrypt);
272
+ // Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
273
+ // recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
274
+ // just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
275
+ // never cleared, so switching focus between To/Cc/Bcc repeatedly doesn't re-trigger lookups.
276
+ const [recipientStatuses, setRecipientStatuses] = useState<Record<string, RecipientEncryptionStatus>>({});
277
+ // Addresses whose lookup is on the wire (so the blur handlers and the prefilled-recipient effect don't
278
+ // look one up twice), and a counter bumped by a From switch so a lookup for the previous sender is dropped.
279
+ const lookupsInFlightRef = useRef(new Set<string>());
280
+ const discoveryGenerationRef = useRef(0);
281
+ // A failed lookup stores no status: the recipient is simply "not known to be encryptable" (fail open - see `encryptionRequirement.ts`), and
282
+ // the lookup is retried in the background with the same backoff as the mailbox/policy load, without a word to the user.
283
+ const lookupFailuresRef = useRef(new Map<string, number>());
284
+ const lookupRetryTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
285
+ const [lookupRetryToken, setLookupRetryToken] = useState(0);
286
+ // A send refused *before it left* because signing/encryption was asked for (or the loaded policy applies it) but can't happen right now
287
+ // (keys locked, Bcc recipients on an encrypted message, a recipient without a key) - never a silent downgrade to plaintext; the user
288
+ // has to explicitly pick the override.
289
+ const [securityBlock, setSecurityBlock] = useState<SendBlock | null>(null);
290
+ // The schedule time of the send that got blocked (undefined for an immediate send), so the banners'
291
+ // override buttons replay the same kind of send the user originally asked for.
292
+ const [blockedScheduleIso, setBlockedScheduleIso] = useState<string | undefined>();
293
+ // Which crypto toggles were ever shown during this compose session (per sending mailbox). Keys that
294
+ // lock after that point must block the send rather than quietly dropping the signature/encryption.
295
+ const offeredCryptoRef = useRef({ sign: false, encrypt: false });
296
+
297
+ // Draft autosave / discard bookkeeping.
298
+ const [seededHtml, setSeededHtml] = useState<string | undefined>();
299
+ const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
300
+ const [closePrompt, setClosePrompt] = useState<ClosePrompt | null>(null);
301
+ /** A Close (saving) or Discard (deleting) is on the wire - the window closes once it succeeds. */
302
+ const [closing, setClosing] = useState(false);
303
+ const [discardError, setDiscardError] = useState<string | null>(null);
304
+ /** Why the last save failed, cleared by the next successful one. */
305
+ const saveErrorRef = useRef<string | null>(null);
306
+ /** `<draftUid>:<content key>` of the last successful save. */
307
+ const lastSavedRef = useRef<string | null>(null);
308
+ /** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
309
+ const pendingSaveRef = useRef(false);
310
+ /** Fires the save when an edit has waited `autosaveMaxWaitMs` without the debounce ever getting a pause to fire in. */
311
+ const maxWaitTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
312
+ /** Send was pressed and is being validated/handed over: a second press (a double click, the shortcut held or repeated) does nothing. */
313
+ const submittingRef = useRef(false);
314
+ /** Attachment/inline-image uploads on the wire, and who is waiting for them to finish (Send pressed meanwhile). */
315
+ const uploadsInFlightRef = useRef(0);
316
+ const uploadWaitersRef = useRef<(() => void)[]>([]);
317
+ /** The latest save request, if any - each save is chained after the one before it. Always settles (never
318
+ * rejects), to the saved message, or `undefined` when that save failed. */
319
+ const saveInFlightRef = useRef<Promise<Message | undefined>>(Promise.resolve(undefined));
320
+ /** Set once the window has been sent/closed/discarded - nothing more gets autosaved after that. */
321
+ const finishedRef = useRef(false);
322
+
323
+ // A mailbox with no keys enrolled yet just means sign/encrypt stay unavailable for this compose session -
324
+ // encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
325
+ // The mailbox and the encryption policy load in the background and *nothing waits for them*: until both have loaded, the message is treated as
326
+ // not encrypted - its draft saves, Close and Send work, and no message about it is shown (see `encryptionRequirement.ts`). A failed load is
327
+ // retried silently with backoff (`cryptoRetryDelaysMs`); if it later turns out the message must be encrypted, `encryptionDecided` (below) starts
328
+ // to apply - see the transition rules there.
329
+
330
+ useEffect(() => {
331
+ let cancelled = false;
332
+ listMailboxes({ limit: 100 }, client)
333
+ .then((result) => {
334
+ if (cancelled) {
335
+ return;
336
+ }
337
+ // Listed with the caller's own (primary) mailbox first, and defaulted to below.
338
+ const ordered = orderMailboxes(result, userUid);
339
+ setMailboxes(ordered);
340
+ // Never default to a mailbox already known to be view-only (see the effect below for one
341
+ // that turns out to be).
342
+ const candidates = ordered.filter((mb) => peekMailboxWritability(mb, userUid, trusted) !== false);
343
+ setFromMailboxUid((current) => current ?? primaryMailboxUid(candidates, userUid));
344
+ })
345
+ .catch((err) => {
346
+ // Only fatal when there's no mailbox to fall back on - a session that already names one just
347
+ // loses the From picker.
348
+ if (!cancelled && !session.mailboxUid) {
349
+ setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
350
+ }
351
+ });
352
+ return () => {
353
+ cancelled = true;
354
+ };
355
+ }, []);
356
+
357
+ useEffect(() => {
358
+ if (!mailboxUid) {
359
+ return;
360
+ }
361
+ let cancelled = false;
362
+ let timer: ReturnType<typeof setTimeout> | undefined;
363
+ // A retry only fetches what's still missing.
364
+ let mailboxLoaded = mailbox?.uid === mailboxUid;
365
+ let policyLoaded = !!encryptionPolicy;
366
+ let retries = 0;
367
+ async function load() {
368
+ await Promise.all([
369
+ mailboxLoaded ||
370
+ getMailbox(mailboxUid!, {}, client).then(
371
+ (result) => {
372
+ mailboxLoaded = true;
373
+ if (!cancelled) {
374
+ setMailbox(result);
375
+ }
376
+ },
377
+ () => undefined,
378
+ ),
379
+ policyLoaded ||
380
+ getEncryptionPolicy(client).then(
381
+ (result) => {
382
+ policyLoaded = true;
383
+ if (!cancelled) {
384
+ setEncryptionPolicy(result);
385
+ }
386
+ },
387
+ () => undefined,
388
+ ),
389
+ ]);
390
+ if (!cancelled && (!mailboxLoaded || !policyLoaded) && retries < cryptoRetryDelaysMs.length) {
391
+ timer = setTimeout(() => void load(), cryptoRetryDelaysMs[retries++]);
392
+ }
393
+ }
394
+ void load();
395
+ return () => {
396
+ cancelled = true;
397
+ clearTimeout(timer);
398
+ };
399
+ }, [mailboxUid]);
400
+
401
+ useEffect(() => {
402
+ if (!mailboxUid) {
403
+ return;
404
+ }
405
+ // Cancellable so a slow response for a mailbox the user has since switched away from can't point
406
+ // the new draft at the wrong mailbox's Drafts folder.
407
+ let cancelled = false;
408
+ listFolders(mailboxUid, client)
409
+ .then((folders) => {
410
+ if (!cancelled) {
411
+ setDraftsFolderUid(folders.find((f) => f.type === "drafts")?.uid);
412
+ }
413
+ })
414
+ .catch((err) => {
415
+ if (!cancelled) {
416
+ setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder.");
417
+ }
418
+ });
419
+ return () => {
420
+ cancelled = true;
421
+ };
422
+ }, [mailboxUid]);
423
+
424
+ /**
425
+ * Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
426
+ * caching the result so the small per-recipient indicator below the recipient fields can update
427
+ * without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
428
+ * and not the lookup the background send (`sendJob.ts`) performs again at send time (deliberately: this is a
429
+ * best-effort UI hint, not something a stale value here should be trusted to skip at send time).
430
+ * A mailbox/policy fetch that hasn't resolved yet just leaves that address with no indicator - the effect
431
+ * below runs it again for every current recipient once both have loaded. A failed lookup leaves it without
432
+ * a status too, and is retried silently after `cryptoRetryDelaysMs` (then only on blur).
433
+ *
434
+ * The statuses also drive `encryptionRequirement.ts`: only when every recipient has one, and the loaded policy
435
+ * auto-encrypts for all of them, does the message turn into one that is never saved as a plaintext draft.
436
+ */
437
+ function checkRecipientDiscovery(addresses: ComposeRecipientInput[]) {
438
+ if (!mailbox || !encryptionPolicy) {
439
+ return;
440
+ }
441
+ const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
442
+ const generation = discoveryGenerationRef.current;
443
+ const inFlight = lookupsInFlightRef.current;
444
+ const unchecked = addresses.filter((r) => !(addressCacheKey(r.address) in recipientStatuses) && !inFlight.has(addressCacheKey(r.address)));
445
+ for (const recipient of unchecked) {
446
+ const key = addressCacheKey(recipient.address);
447
+ inFlight.add(key);
448
+ void lookupKeys(mailbox.uid, recipient.address, client)
449
+ .then(
450
+ (lookup) => ({ lookup }),
451
+ () => null,
452
+ )
453
+ .then((result) => {
454
+ inFlight.delete(key);
455
+ if (generation !== discoveryGenerationRef.current) {
456
+ return;
457
+ }
458
+ if (!result) {
459
+ noteLookupFailed(key);
460
+ return;
461
+ }
462
+ lookupFailuresRef.current.delete(key);
463
+ const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
464
+ setRecipientStatuses((prev) => ({ ...prev, [key]: status }));
465
+ });
466
+ }
467
+ }
468
+
469
+ /** Schedules the next automatic, silent retry of a failed lookup (until the backoff has run out; a later blur tries again). */
470
+ function noteLookupFailed(address: string) {
471
+ const failures = (lookupFailuresRef.current.get(address) ?? 0) + 1;
472
+ lookupFailuresRef.current.set(address, failures);
473
+ if (failures > cryptoRetryDelaysMs.length) {
474
+ return;
475
+ }
476
+ const timer = setTimeout(() => {
477
+ lookupRetryTimersRef.current.delete(timer);
478
+ setLookupRetryToken((n) => n + 1);
479
+ }, cryptoRetryDelaysMs[failures - 1]);
480
+ lookupRetryTimersRef.current.add(timer);
481
+ }
482
+
483
+ useEffect(
484
+ () => () => {
485
+ for (const timer of lookupRetryTimersRef.current) {
486
+ clearTimeout(timer);
487
+ }
488
+ },
489
+ [],
490
+ );
491
+
492
+ // Prefilled recipients (a reply's To/Cc) are never blurred, and a blur before the mailbox/policy loaded
493
+ // was ignored - look every current recipient up as soon as both are available (and again on a lookup retry).
494
+ useEffect(() => {
495
+ checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
496
+ }, [mailbox, encryptionPolicy, lookupRetryToken]);
497
+
498
+ // Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it (not for a window re-opened around a composed message)
499
+ // plus any quoted original message (laid out by `buildComposeBodyHtml()`: an empty first paragraph for the
500
+ // caret, then the signature, then the quote), before `RichTextEditor` ever mounts (gated by `contentReady`
501
+ // below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
502
+ // content and never re-syncs from a later prop change, so this has to resolve before that first
503
+ // mount, not after. A signature-list failure is best-effort, same as every other supplementary,
504
+ // non-blocking fetch in this codebase (e.g. `ConversationList`'s own child-message fetch) — no
505
+ // signature is a completely legitimate outcome, so this falls back to just the quoted content
506
+ // (if any) rather than surfacing an error over what's a cosmetic nicety.
507
+ //
508
+ // A reply or forward opens before its quoted original has been fetched (`quotePending`, see
509
+ // `OpenComposeInput.pending`). The body is seeded as soon as the signature is here, without the quote, so the editor is usable
510
+ // at once however slow the original is to come; the quote is added at the end of the editor's document when it arrives
511
+ // (`appendHtml` below) - the same place, under the signature, that a body seeded with it would have had it.
512
+ const [defaultSignature, setDefaultSignature] = useState<{ html?: string } | undefined>();
513
+ useEffect(() => {
514
+ // Fetched once, for whichever mailbox is the sender when compose opens - switching From later must not
515
+ // overwrite what the user has already written (the editor itself never re-syncs).
516
+ if (!mailboxUid || contentReady || defaultSignature) {
517
+ return;
518
+ }
519
+ listMailSignatures(mailboxUid, {}, client)
520
+ .then((signatures) => {
521
+ const signature = signatures.find((s) =>
522
+ signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward,
523
+ );
524
+ setDefaultSignature({ html: signature?.contentHtml });
525
+ })
526
+ .catch(() => setDefaultSignature({}));
527
+ }, [mailboxUid, signatureContext]);
528
+ const [seededWithoutQuote, setSeededWithoutQuote] = useState(false);
529
+ useEffect(() => {
530
+ if (contentReady || !defaultSignature) {
531
+ return;
532
+ }
533
+ const seeded = buildComposeBodyHtml(defaultSignature.html, quotePending ? undefined : initialQuotedHtml);
534
+ setHtml(seeded);
535
+ setSeededHtml(seeded);
536
+ setSeededWithoutQuote(!!quotePending);
537
+ setContentReady(true);
538
+ markComposePhase(id, "body");
539
+ }, [defaultSignature, quotePending, initialQuotedHtml]);
540
+ /** What the editor adds to the end of its document once the quote a reply was opened without has arrived. */
541
+ const lateQuoteHtml =
542
+ seededWithoutQuote && !quotePending && initialQuotedHtml && defaultSignature
543
+ ? buildComposeBodyHtml(defaultSignature.html, initialQuotedHtml).slice(buildComposeBodyHtml(defaultSignature.html) === "" ? "<p></p>".length : buildComposeBodyHtml(defaultSignature.html).length)
544
+ : undefined;
545
+
546
+ useEffect(() => {
547
+ if (!mailboxUid || !draftsFolderUid || draft) {
548
+ return;
549
+ }
550
+ let cancelled = false;
551
+ // `session.threading` is what makes a reply a reply: the server writes it into the relayed MIME's
552
+ // `In-Reply-To`/`References` and groups the message into the thread being answered. Recorded on
553
+ // every draft this window creates, including the replacement one a From switch makes - the thread
554
+ // doesn't change because the sending mailbox did.
555
+ createDraft(mailboxUid, draftsFolderUid, session.threading, client)
556
+ .then((created) => {
557
+ if (cancelled) {
558
+ // The sender changed while this was in flight - discard the now-orphaned draft.
559
+ void deleteMessage(created.uid, created.version, client).catch(() => undefined);
560
+ return;
561
+ }
562
+ setDraft(created);
563
+ markComposePhase(id, "draft");
564
+ // Only now that the replacement exists is it safe to discard the draft(s) a From switch
565
+ // superseded - if creating this one had failed, the earlier draft is still there.
566
+ for (const superseded of supersededDraftsRef.current.splice(0)) {
567
+ void deleteSupersededDraft(superseded);
568
+ }
569
+ })
570
+ .catch((err) => {
571
+ if (!cancelled) {
572
+ setDraftError(err instanceof ApiRequestError ? err.message : "Could not start a new draft.");
573
+ }
574
+ });
575
+ return () => {
576
+ cancelled = true;
577
+ };
578
+ }, [mailboxUid, draftsFolderUid, draft, session.threading]);
579
+
580
+ // Drafts a From switch superseded are otherwise only deleted once their replacement exists - closing the
581
+ // window (or sending, which closes it) first would orphan them.
582
+ useEffect(
583
+ () => () => {
584
+ for (const superseded of supersededDraftsRef.current.splice(0)) {
585
+ void deleteSupersededDraft(superseded);
586
+ }
587
+ },
588
+ [],
589
+ );
590
+
591
+ /**
592
+ * Deletes a draft a From switch replaced. An autosave of it may still be on the wire - it bumps the
593
+ * draft's version (see `saveDraftNow()`, which keeps `superseded.version` current while it's queued), so
594
+ * the delete waits for it and uses its version; a delete that still fails (e.g. another save of it landed
595
+ * meanwhile) is retried once with the version the server currently holds - unless it's no longer a draft
596
+ * (another window sent or scheduled it), which is left alone.
597
+ * Best-effort, never rejects.
598
+ */
599
+ async function deleteSupersededDraft(superseded: Message): Promise<void> {
600
+ const saved = await saveInFlightRef.current;
601
+ // Once it's out of `supersededDraftsRef` a landing save can't update `superseded.version` any more.
602
+ const version = saved?.uid === superseded.uid ? saved.version : superseded.version;
603
+ try {
604
+ await deleteMessage(superseded.uid, version, client);
605
+ } catch {
606
+ try {
607
+ const fresh = await getMessage(superseded.uid, client);
608
+ if (isStillDraft(fresh, superseded.folderUid)) {
609
+ await deleteMessage(fresh.uid, fresh.version, client);
610
+ }
611
+ } catch {
612
+ // Already gone, or the server is unreachable - nothing more to do from here.
613
+ }
614
+ }
615
+ }
616
+
617
+ // The From picker lists every mailbox straight away and drops view-only ones as each check answers - only
618
+ // mailboxes the caller can create a draft in are worth offering.
619
+ const writability = useMailboxWritability(mailboxes, userUid, trusted);
620
+ const fromOptions = mailboxes.filter((mb) => mb.uid === mailboxUid || writability[mb.uid] !== false);
621
+
622
+ // The sender turned out to be view-only (e.g. a reply to a message in a mailbox shared read-only, or a
623
+ // first-listed shared mailbox): switch to one the caller can actually send from, preferring their own.
624
+ const fromIsViewOnly = !!mailboxUid && writability[mailboxUid] === false;
625
+ useEffect(() => {
626
+ if (!fromIsViewOnly || hasUploads) {
627
+ return;
628
+ }
629
+ const ordered = orderMailboxes(mailboxes, userUid);
630
+ const alternative = ordered.find((mb) => writability[mb.uid] === true) ?? ordered.find((mb) => writability[mb.uid] === undefined);
631
+ if (alternative) {
632
+ handleFromChange(alternative.uid);
633
+ }
634
+ }, [fromIsViewOnly, writability]);
635
+
636
+ /** Switches the sending mailbox: supersedes the current (unsent, upload-free) draft and resets the
637
+ * per-mailbox state, so the effects above start a fresh draft and crypto context in the new mailbox -
638
+ * the old draft is deleted only once the new one has been created. Recipients, subject, and body are
639
+ * kept. */
640
+ function handleFromChange(nextMailboxUid: string) {
641
+ if (nextMailboxUid === mailboxUid || hasUploads) {
642
+ return;
643
+ }
644
+ if (draft) {
645
+ supersededDraftsRef.current.push(draft);
646
+ }
647
+ setDraft(null);
648
+ setDraftsFolderUid(undefined);
649
+ setDraftError(null);
650
+ setFolderError(null);
651
+ setMailbox(null);
652
+ setRecipientStatuses({});
653
+ discoveryGenerationRef.current += 1;
654
+ lookupsInFlightRef.current = new Set();
655
+ lookupFailuresRef.current = new Map();
656
+ setEncryptRequested(!!initialEncrypt);
657
+ setSecurityBlock(null);
658
+ offeredCryptoRef.current = { sign: false, encrypt: false };
659
+ setFromMailboxUid(nextMailboxUid);
660
+ }
661
+
662
+ /**
663
+ * Click-and-drag resize, from the window's own top/left edges (and the top-left corner, for both
664
+ * at once) — the window is anchored bottom-right (see `ComposeContext.tsx`'s stack), so growing it
665
+ * always means extending up and/or left, never down/right. `onMove`/`onUp` are fresh closures
666
+ * created per drag gesture (captured over this gesture's own start position/size) rather than
667
+ * stable component-level functions, specifically so `removeEventListener` in `onUp` always removes
668
+ * the exact listener `onMove`/`onUp` themselves just added — a version defined once per render and
669
+ * reused across gestures would need extra ref-juggling to avoid removing a stale (already-replaced)
670
+ * listener instead of the current one.
671
+ */
672
+ function handleResizeStart(edge: ResizeEdge) {
673
+ return (e: React.PointerEvent) => {
674
+ e.preventDefault();
675
+ const rect = windowRef.current!.getBoundingClientRect();
676
+ const startX = e.clientX;
677
+ const startY = e.clientY;
678
+ const startWidth = rect.width;
679
+ const startHeight = rect.height;
680
+
681
+ function onMove(ev: PointerEvent) {
682
+ const maxWidth = window.innerWidth - VIEWPORT_MARGIN;
683
+ const maxHeight = window.innerHeight - VIEWPORT_MARGIN;
684
+ const width = edge === "top" ? startWidth : Math.min(Math.max(startWidth - (ev.clientX - startX), MIN_WIDTH), maxWidth);
685
+ const height = edge === "left" ? startHeight : Math.min(Math.max(startHeight - (ev.clientY - startY), MIN_HEIGHT), maxHeight);
686
+ setManualSize({ width, height });
687
+ }
688
+ function onUp() {
689
+ window.removeEventListener("pointermove", onMove);
690
+ window.removeEventListener("pointerup", onUp);
691
+ }
692
+ window.addEventListener("pointermove", onMove);
693
+ window.addEventListener("pointerup", onUp);
694
+ };
695
+ }
696
+
697
+ function toggleExpanded() {
698
+ setExpanded((v) => !v);
699
+ setManualSize(null);
700
+ }
701
+
702
+ /** Uploads `file` as an attachment on the current draft, resolving to a URL the editor can preview
703
+ * it at immediately — see `BaseMailComposeRoute.rewriteInlineImageSources()`'s doc comment for how
704
+ * that URL gets swapped for the message's real inline `cid:` reference at send time. Resolves to
705
+ * `null` (surfacing `attachError` itself, reusing the same state "Attach files" already has) rather
706
+ * than throwing: unlike "Attach files" (`disabled={!draft}`), "Insert image" in the formatting
707
+ * toolbar is only gated on the *editor* being mounted, not on the draft existing yet — those two
708
+ * things load in parallel, so a real (if narrow) window exists where the button is clickable before
709
+ * `draft` resolves, and this needs to fail gracefully rather than crash on a null draft.
710
+ */
711
+ async function handleUploadImage(file: File): Promise<string | null> {
712
+ if (!draft) {
713
+ setAttachError("Please wait for the draft to finish loading before inserting an image.");
714
+ return null;
715
+ }
716
+ uploadStarted();
717
+ try {
718
+ const attachment = await uploadAttachment(draft.uid, file);
719
+ setHasUploads(true);
720
+ void refreshDraftVersion(draft.uid);
721
+ return attachmentContentUrl(attachment.uid);
722
+ } catch (err) {
723
+ setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
724
+ return null;
725
+ } finally {
726
+ uploadFinished();
727
+ }
728
+ }
729
+
730
+ /** Send waits for every upload started before it: see `submit()`. */
731
+ function uploadStarted() {
732
+ uploadsInFlightRef.current += 1;
733
+ }
734
+ function uploadFinished() {
735
+ uploadsInFlightRef.current -= 1;
736
+ if (uploadsInFlightRef.current === 0) {
737
+ for (const resolve of uploadWaitersRef.current.splice(0)) {
738
+ resolve();
739
+ }
740
+ }
741
+ }
742
+
743
+ /** Uploading an attachment bumps the draft's version server-side (it records `hasAttachments`), so a later
744
+ * Discard would otherwise send a stale `?version=`. Best-effort: Discard also retries with the server's
745
+ * current version. */
746
+ async function refreshDraftVersion(uid: string) {
747
+ try {
748
+ const fresh = await getMessage(uid, client);
749
+ setDraft((prev) => (prev?.uid === fresh.uid && fresh.version > prev.version ? { ...prev, version: fresh.version } : prev));
750
+ } catch {
751
+ // Discard's own retry covers a version this couldn't refresh.
752
+ }
753
+ }
754
+
755
+ // The "Attach files" input is itself `disabled` until `draft` resolves (see its `disabled={!draft}`
756
+ // below), so this can only ever fire once `draft` is set — no defensive null check needed, matching
757
+ // this codebase's established pattern for the same class of "always non-null by the time it's
758
+ // called" value (e.g. `ComposeToolbar.run()`). No separate empty-`files` guard either: the loop
759
+ // below is already a no-op when there's nothing to iterate.
760
+ async function handleFilesSelected(e: ChangeEvent<HTMLInputElement>) {
761
+ const files = Array.from(e.target.files ?? []);
762
+ e.target.value = "";
763
+ setAttachError(null);
764
+ uploadStarted();
765
+ try {
766
+ for (const file of files) {
767
+ try {
768
+ const attachment = await uploadAttachment(draft!.uid, file);
769
+ setHasUploads(true);
770
+ setAttachments((prev) => [...prev, attachment]);
771
+ void refreshDraftVersion(draft!.uid);
772
+ } catch (err) {
773
+ setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
774
+ }
775
+ }
776
+ } finally {
777
+ uploadFinished();
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Send (or, given `scheduledSendTimeIso`, Send later) - and get out of the way. The window is the place the message is *written*; nothing
783
+ * about getting it to the server is waited for here.
784
+ *
785
+ * 1. **Validate synchronously**, from what the window already knows (no request): a recipient is required; and if the message is one
786
+ * that must be signed or encrypted and can't be right now (keys locked, Bcc on an encrypted message, a recipient with no key, an
787
+ * attachment that can't be signed) that is shown right here and the window stays open - the sender fixes it or picks the override.
788
+ * An attachment still uploading is waited for, inline (Send says so), never left behind.
789
+ * 2. **Close immediately** (`onClose()`): `startSend()` (see `sendJob.ts`) takes over with a snapshot of the message. From here the only
790
+ * feedback is the Outbox indicator; a failure at any stage is a sticky pop-up with the reason and Retry / Open draft.
791
+ *
792
+ * Pressed twice (a double click, the shortcut repeated) it sends once: the first press sets `submittingRef` before any `await`, and
793
+ * `startSend()` itself refuses a draft that is already being sent. `forcePlaintext` is the override buttons' choice.
794
+ */
795
+ async function submit(forcePlaintext: boolean, scheduledSendTimeIso?: string) {
796
+ if (submittingRef.current || finishedRef.current) {
797
+ return;
798
+ }
799
+ if (parseAddresses(latestRef.current.to).length === 0) {
800
+ setSendError("At least one recipient is required.");
801
+ return;
802
+ }
803
+ submittingRef.current = true;
804
+ setSendError(null);
805
+ setSecurityBlock(null);
806
+ try {
807
+ if (uploadsInFlightRef.current > 0) {
808
+ setWaitingForUploads(true);
809
+ await new Promise<void>((resolve) => uploadWaitersRef.current.push(resolve));
810
+ // The editor puts an image it has just uploaded into the message a moment after the upload resolves: let the window catch up.
811
+ await new Promise((resolve) => setTimeout(resolve, 0));
812
+ setWaitingForUploads(false);
813
+ }
814
+ const current = latestRef.current;
815
+ const request: SendRequest = {
816
+ draft: current.draft!,
817
+ mailboxUid: current.mailboxUid!,
818
+ mailbox: current.mailbox ?? undefined,
819
+ policy: current.encryptionPolicy ?? undefined,
820
+ toText: current.to,
821
+ ccText: current.cc,
822
+ bccText: current.bcc,
823
+ to: parseAddresses(current.to),
824
+ cc: parseAddresses(current.cc),
825
+ bcc: parseAddresses(current.bcc),
826
+ subject: current.subject,
827
+ html: current.html,
828
+ attachments: current.attachments,
829
+ requestReceipt: current.requestReceipt,
830
+ scheduledSendTime: scheduledSendTimeIso,
831
+ signEnabled: current.signEnabled,
832
+ offeredSign: offeredCryptoRef.current.sign,
833
+ offeredEncrypt: offeredCryptoRef.current.encrypt,
834
+ encryptRequested: current.encryptRequested,
835
+ forcePlaintext,
836
+ saved: saveInFlightRef.current,
837
+ threading: session.threading,
838
+ };
839
+ if (request.to.length === 0) {
840
+ setSendError("At least one recipient is required.");
841
+ return;
842
+ }
843
+ const early = decideSend(sendDecisionInput(request, request.mailbox, request.policy, current.statuses, false));
844
+ if (early.action === "rejected") {
845
+ setSendError(early.message);
846
+ return;
847
+ }
848
+ if (early.action === "blocked") {
849
+ setBlockedScheduleIso(scheduledSendTimeIso);
850
+ setSecurityBlock(early.block);
851
+ if (early.block.keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
852
+ void handleUnlockForCrypto();
853
+ }
854
+ return;
855
+ }
856
+ pendingSaveRef.current = false;
857
+ finishedRef.current = true;
858
+ if (!startSend(request, client)) {
859
+ finishedRef.current = false;
860
+ setSendError("This message is already being sent.");
861
+ return;
862
+ }
863
+ onClose();
864
+ } finally {
865
+ submittingRef.current = false;
866
+ setWaitingForUploads(false);
867
+ }
868
+ }
869
+
870
+ function handleScheduleSend(scheduledSendTimeIso: string) {
871
+ setSchedulePickerOpen(false);
872
+ void submit(false, scheduledSendTimeIso);
873
+ }
874
+
875
+ async function handleUnlockForCrypto() {
876
+ try {
877
+ // Only reachable when hasEnrolledSigningKey/hasEnrolledEncryptionKey found a real enrolled key
878
+ // in mailbox.keys (the unlock button below, or a blocked send()), so neither is empty/undefined here.
879
+ await requestUnlock(mailbox!.uid, mailbox!.keys!);
880
+ setUnlockRefresh((n) => n + 1);
881
+ } catch {
882
+ // User dismissed the unlock dialog - nothing to do, the toggles below simply stay hidden.
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Saves the window's current recipients/subject/body onto its server draft via `assembleDraft()` (the
888
+ * same plaintext assembly a normal send uses). Chained after any save already on the wire - two
889
+ * overlapping assemblies of the same draft race on its version and the later one is rejected - and reads
890
+ * `latestRef` only once it actually runs, so it saves what was last typed (skipping the request when the
891
+ * save before it already stored exactly that). Whether a save may happen at all is re-checked at that same
892
+ * moment: while it waited, the message may have become one that must not be stored as plaintext (Encrypt
893
+ * turned on, a recipient that auto-encrypts), or the window may have been sent or finished - then it
894
+ * resolves `{ skipped: true }` without a request (`message` then being the previous save's result). Never
895
+ * rejects; `message` is `undefined` when the save failed.
896
+ */
897
+ function saveDraftNow(): Promise<{ message?: Message; skipped?: boolean }> {
898
+ const target = latestRef.current.draft!;
899
+ const previous = saveInFlightRef.current;
900
+ pendingSaveRef.current = false;
901
+ clearTimeout(maxWaitTimerRef.current);
902
+ maxWaitTimerRef.current = undefined;
903
+ setSaveStatus("saving");
904
+ const outcome = (async (): Promise<{ message?: Message; skipped?: boolean }> => {
905
+ const before = await previous;
906
+ const current = latestRef.current;
907
+ if (current.autosaveSuppressed || finishedRef.current) {
908
+ setSaveStatus("idle");
909
+ // Still the newest copy the server holds, for the version a later delete needs.
910
+ return { skipped: true, message: before };
911
+ }
912
+ const savedKey = `${target.uid}:${current.contentKey}`;
913
+ if (before?.uid === target.uid && lastSavedRef.current === savedKey) {
914
+ setSaveStatus("saved");
915
+ return { message: before };
916
+ }
917
+ try {
918
+ const saved = await assembleDraft(target.uid, {
919
+ to: parseAddresses(current.to),
920
+ cc: parseAddresses(current.cc),
921
+ bcc: parseAddresses(current.bcc),
922
+ subject: current.subject,
923
+ html: current.html,
924
+ }, client);
925
+ lastSavedRef.current = savedKey;
926
+ saveErrorRef.current = null;
927
+ // A From switch may have replaced the draft while this was in flight - never resurrect it,
928
+ // but do keep its superseded copy's version current so its pending delete still matches.
929
+ setDraft((prev) => (prev?.uid === saved.uid ? saved : prev));
930
+ const superseded = supersededDraftsRef.current.find((d) => d.uid === saved.uid);
931
+ if (superseded) {
932
+ superseded.version = saved.version;
933
+ }
934
+ setSaveStatus("saved");
935
+ return { message: saved };
936
+ } catch (err) {
937
+ saveErrorRef.current = err instanceof ApiRequestError ? err.message : "the server couldn't be reached";
938
+ setSaveStatus("error");
939
+ return {};
940
+ }
941
+ })();
942
+ saveInFlightRef.current = outcome.then((result) => result.message);
943
+ return outcome;
944
+ }
945
+
946
+ /** Keeps the window open after its content couldn't be saved, offering Discard or Keep editing. */
947
+ function showSaveFailedPrompt() {
948
+ setClosePrompt({
949
+ title: "Couldn't save this draft",
950
+ message: `It couldn't be saved (${saveErrorRef.current!.replace(/\.$/, "")}). Keep editing and try again, or discard it.`,
951
+ });
952
+ }
953
+
954
+ /**
955
+ * Deletes `current` (this window's server draft) once any save already on the wire has landed, so the delete
956
+ * carries the draft's current version. A delete rejected for a stale version (404/409: e.g. an attachment
957
+ * upload bumped it) is retried once with the version the server holds - and a draft the server no longer has
958
+ * counts as deleted. The retry only happens while the server copy is still an unsent draft in the same folder.
959
+ * Rejects when the draft couldn't be deleted.
960
+ */
961
+ async function deleteDraft(current: Message): Promise<void> {
962
+ const saved = await saveInFlightRef.current;
963
+ const version = Math.max(current.version, saved?.uid === current.uid ? saved.version : 0);
964
+ try {
965
+ await deleteMessage(current.uid, version, client);
966
+ } catch (err) {
967
+ if (!(err instanceof ApiRequestError) || (err.status !== 404 && err.status !== 409)) {
968
+ throw err;
969
+ }
970
+ const fresh = await getMessage(current.uid, client).catch((getErr: unknown) => {
971
+ if (getErr instanceof ApiRequestError && getErr.status === 404) {
972
+ return null;
973
+ }
974
+ throw getErr;
975
+ });
976
+ if (!fresh) {
977
+ return;
978
+ }
979
+ // The version changed because another window sent or scheduled it - that message isn't ours to delete.
980
+ if (!isStillDraft(fresh, current.folderUid)) {
981
+ throw new ApiRequestError(NO_LONGER_A_DRAFT_MESSAGE, 409);
982
+ }
983
+ await deleteMessage(fresh.uid, fresh.version, client);
984
+ }
985
+ }
986
+
987
+ /**
988
+ * Discards the window's draft and closes it. With nothing the user would lose (a blank draft) it closes
989
+ * straight away and deletes the draft in the background; otherwise it closes only once the draft is
990
+ * deleted, and a delete that fails keeps the window open with the error.
991
+ */
992
+ async function discardNow() {
993
+ finishedRef.current = true;
994
+ pendingSaveRef.current = false;
995
+ setClosePrompt(null);
996
+ setDiscardError(null);
997
+ const current = draft;
998
+ if (!current || !hasUserContent) {
999
+ if (current) {
1000
+ void deleteDraft(current).catch(() => undefined);
1001
+ }
1002
+ onClose();
1003
+ return;
1004
+ }
1005
+ setClosing(true);
1006
+ try {
1007
+ await deleteDraft(current);
1008
+ } catch (err) {
1009
+ finishedRef.current = false;
1010
+ setClosing(false);
1011
+ setDiscardError(`Couldn't discard this draft: ${err instanceof ApiRequestError ? err.message : "the server couldn't be reached."}`);
1012
+ return;
1013
+ }
1014
+ onClose();
1015
+ }
1016
+
1017
+ /** "Discard draft": confirms first whenever there's anything the user would lose. */
1018
+ function handleDiscard() {
1019
+ if (hasUserContent) {
1020
+ setClosePrompt({ title: DISCARD_TITLE, message: "This permanently deletes this draft and everything you've written in it." });
1021
+ } else {
1022
+ void discardNow();
1023
+ }
1024
+ }
1025
+
1026
+ /** "Close": keeps the draft (saving any unsaved edits first, and closing only once that save succeeded) -
1027
+ * unless there's nothing in it worth keeping, or it can't be saved because it's really headed for encryption
1028
+ * (asked for, or applied by the loaded policy): that needs the same confirmation as a discard. Whatever is
1029
+ * *unknown* (settings loading or failed, a lookup pending) is not a reason to hold the window: it is saved like any
1030
+ * other message. A failed save keeps the window open with Discard / Keep editing. */
1031
+ async function handleClose() {
1032
+ if (!hasUserContent) {
1033
+ void discardNow();
1034
+ return;
1035
+ }
1036
+ if (encryptionDecided) {
1037
+ setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
1038
+ return;
1039
+ }
1040
+ if (!draft || !contentReady) {
1041
+ saveErrorRef.current = draftError ?? "the draft hasn't been created yet";
1042
+ showSaveFailedPrompt();
1043
+ return;
1044
+ }
1045
+ if (lastSavedRef.current !== `${draft.uid}:${contentKey}`) {
1046
+ setClosing(true);
1047
+ const outcome = await saveDraftNow();
1048
+ setClosing(false);
1049
+ if (outcome.skipped) {
1050
+ // It became a message that mustn't be saved as plaintext while waiting on an earlier save.
1051
+ setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
1052
+ return;
1053
+ }
1054
+ if (!outcome.message) {
1055
+ showSaveFailedPrompt();
1056
+ return;
1057
+ }
1058
+ }
1059
+ finishedRef.current = true;
1060
+ onClose();
1061
+ }
1062
+
1063
+ // A plain read from keySession.ts's module-level session store, not React state - see that module's
1064
+ // own doc comment. Cheap enough to read fresh on every render rather than caching in state.
1065
+ const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
1066
+ const hasEnrolledSigningKey = !!findActivePublicKey(mailbox?.keys ?? [], "sign");
1067
+ const hasEnrolledEncryptionKey = !!findActivePublicKey(mailbox?.keys ?? [], "encrypt");
1068
+ // This mailbox has a real signing/encryption key on file, but this session hasn't unlocked it yet -
1069
+ // the sign/encrypt toggles below stay hidden until it has, so this is the only way to reach them
1070
+ // without first stumbling into Mail generally (which no longer force-prompts on its own - see
1071
+ // `MailShell`'s `blocking={false}`). `getUnlockedKeys()` is re-read as a plain module value above, so
1072
+ // bumping `unlockRefresh` (after a successful unlock, or on any key-session event for this mailbox -
1073
+ // see the `subscribeKeySession()` effect) is what makes this line (and the toggles it gates) reflect
1074
+ // it on the next render.
1075
+ const needsUnlockForCrypto = (hasEnrolledSigningKey || hasEnrolledEncryptionKey) && !unlockedKeys;
1076
+
1077
+ // Keys locking (idle timeout, logout elsewhere, ...) or unlocking from another window must update the
1078
+ // toggles/unlock affordance right away, not on whatever unrelated render happens next.
1079
+ useEffect(() => subscribeKeySession((event) => {
1080
+ if (event.mailboxUid === mailboxUid) {
1081
+ setUnlockRefresh((n) => n + 1);
1082
+ }
1083
+ }), [mailboxUid]);
1084
+
1085
+ useEffect(() => {
1086
+ offeredCryptoRef.current = {
1087
+ sign: offeredCryptoRef.current.sign || !!unlockedKeys?.signingPrivateKey,
1088
+ encrypt: offeredCryptoRef.current.encrypt || !!unlockedKeys?.encryptionPrivateKey,
1089
+ };
1090
+ });
1091
+
1092
+ // Autosave. "User content" is anything beyond what compose opened with (prefilled recipients/subject,
1093
+ // seeded signature/quote) - an untouched window has nothing worth saving or confirming a discard of.
1094
+ const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
1095
+ const baselineKey = JSON.stringify([baselineTo ?? "", baselineCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
1096
+ const hasUserContent = contentKey !== baselineKey || hasUploads;
1097
+ // Whether this message is *really* headed for encryption - `encryptionRequirement.ts` decides, and it fails open: the message is plain (its
1098
+ // draft saves, Close and Send work, nothing is shown) unless the user asked for encryption or the policy was loaded and applies it to every
1099
+ // recipient (whose keys were all looked up). What has not loaded, or failed to, is not held against the message.
1100
+ //
1101
+ // Transitions, when a later load turns a plain message into an encrypted one (the policy arrives, a slow lookup answers, a recipient is
1102
+ // added): (1) autosave stops at once (`autosaveSuppressed`); (2) a plaintext copy this window already saved is *replaced by an empty draft*
1103
+ // (`scrubSavedDraft()` below) so the plaintext doesn't sit on the server; (3) Close asks first ("Encrypted messages aren't saved as drafts");
1104
+ // (4) Send encrypts (or is refused, with the override, when it can't) - never silently plaintext. And back: when it stops being encrypted
1105
+ // (a recipient removed) autosave simply resumes.
1106
+ const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
1107
+ const currentStatuses = currentAddresses.map((address) => recipientStatuses[addressCacheKey(address)]);
1108
+ const hasEncryptionKey = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
1109
+ const requirement = evaluateEncryptionRequirement({
1110
+ encryptRequested,
1111
+ policy: encryptionPolicy ?? undefined,
1112
+ mailboxLoaded: !!mailbox,
1113
+ hasEncryptionKey,
1114
+ recipients: currentStatuses,
1115
+ });
1116
+ const encryptionDecided = requirement.required;
1117
+ const autosaveSuppressed = encryptionDecided;
1118
+ const latestRef = useRef({
1119
+ draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
1120
+ statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
1121
+ });
1122
+ latestRef.current = {
1123
+ draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
1124
+ statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
1125
+ };
1126
+
1127
+ /** A message that turned out to need encryption after a plaintext copy of it was saved: the saved copy is replaced by an empty draft (the same
1128
+ * uid, so attachments and the folder are undisturbed), chained after any save still on the wire. Best effort - a failure is reported once. */
1129
+ function scrubSavedDraft(target: Message) {
1130
+ lastSavedRef.current = null;
1131
+ setSaveStatus("idle");
1132
+ const previous = saveInFlightRef.current;
1133
+ const scrub = (async (): Promise<Message | undefined> => {
1134
+ await previous;
1135
+ try {
1136
+ // Its answer (the new version) is what a later delete waits for through `saveInFlightRef`, like any save's.
1137
+ return await assembleDraft(target.uid, { to: [], cc: [], bcc: [], subject: "", html: "" }, client);
1138
+ } catch {
1139
+ notify({
1140
+ id: `scrub-draft:${target.uid}`,
1141
+ kind: "warning",
1142
+ title: "An earlier draft copy is still saved",
1143
+ message: "This message needs encryption, but the copy saved before that was known couldn't be replaced. Discarding the draft removes it.",
1144
+ });
1145
+ return undefined;
1146
+ }
1147
+ })();
1148
+ saveInFlightRef.current = scrub;
1149
+ }
1150
+ // Only a message that was really saved (`lastSavedRef` names its draft) has a plaintext copy to replace.
1151
+ useEffect(() => {
1152
+ if (encryptionDecided && draft && lastSavedRef.current?.startsWith(`${draft.uid}:`)) {
1153
+ scrubSavedDraft(draft);
1154
+ }
1155
+ }, [encryptionDecided, draft?.uid]);
1156
+
1157
+ // Recipients a reply could only work out after this window opened (Reply All: whoever the original's headers name)
1158
+ // replace the ones it opened with - but only in a field the user hasn't touched, never over what they typed.
1159
+ useEffect(() => {
1160
+ if (!late) {
1161
+ return;
1162
+ }
1163
+ const current = latestRef.current;
1164
+ if (late.to !== undefined && current.to === (initialTo ?? "")) {
1165
+ setTo(late.to);
1166
+ }
1167
+ if (late.cc !== undefined && current.cc === (initialCc ?? "")) {
1168
+ setCc(late.cc);
1169
+ setShowCcBcc((shown) => shown || late.cc !== "");
1170
+ }
1171
+ }, [late]);
1172
+
1173
+ useEffect(() => {
1174
+ const due =
1175
+ !!draft &&
1176
+ contentReady &&
1177
+ !autosaveSuppressed &&
1178
+ hasUserContent &&
1179
+ lastSavedRef.current !== `${draft.uid}:${contentKey}`;
1180
+ pendingSaveRef.current = due;
1181
+ if (!due) {
1182
+ clearTimeout(maxWaitTimerRef.current);
1183
+ maxWaitTimerRef.current = undefined;
1184
+ return;
1185
+ }
1186
+ // The debounce below restarts on every edit, so steady typing never lets it fire: this timer, started by the first unsaved edit and stopped
1187
+ // by the save that follows, is what saves such a message at a regular interval.
1188
+ if (maxWaitTimerRef.current === undefined) {
1189
+ maxWaitTimerRef.current = setTimeout(() => {
1190
+ maxWaitTimerRef.current = undefined;
1191
+ if (pendingSaveRef.current && !finishedRef.current) {
1192
+ void saveDraftNow();
1193
+ }
1194
+ }, Math.max(autosaveMaxWaitMs, autosaveDelayMs));
1195
+ }
1196
+ const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
1197
+ return () => clearTimeout(timer);
1198
+ }, [draft, contentReady, autosaveSuppressed, hasUserContent, contentKey]);
1199
+
1200
+ // Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
1201
+ // right away rather than losing it.
1202
+ useEffect(
1203
+ () => () => {
1204
+ clearTimeout(maxWaitTimerRef.current);
1205
+ if (pendingSaveRef.current && !finishedRef.current) {
1206
+ void saveDraftNow();
1207
+ }
1208
+ },
1209
+ [],
1210
+ );
1211
+
1212
+ /** Saves an edit still waiting on the debounce now, and resolves once that save - or one already on the
1213
+ * wire - has settled, to whether the window's content is saved. A save that failed keeps the window open
1214
+ * with Discard / Keep editing. A send clears `pendingSaveRef` before it starts, so this never saves mid-send. */
1215
+ async function flushPendingSave(): Promise<boolean> {
1216
+ if (pendingSaveRef.current && !finishedRef.current) {
1217
+ const outcome = await saveDraftNow();
1218
+ if (outcome.skipped) {
1219
+ // Not saved, but not a failure either: it may be headed for encryption (or was sent/discarded meanwhile).
1220
+ return finishedRef.current;
1221
+ }
1222
+ if (!outcome.message) {
1223
+ showSaveFailedPrompt();
1224
+ }
1225
+ return !!outcome.message;
1226
+ }
1227
+ await saveInFlightRef.current;
1228
+ const latest = latestRef.current;
1229
+ if (finishedRef.current || !latest.hasUserContent || (!!latest.draft && lastSavedRef.current === `${latest.draft.uid}:${latest.contentKey}`)) {
1230
+ return true;
1231
+ }
1232
+ // Unsaved because the last save failed (rather than, say, an encrypted message never being saved).
1233
+ if (saveErrorRef.current) {
1234
+ showSaveFailedPrompt();
1235
+ }
1236
+ return false;
1237
+ }
1238
+
1239
+ // Sign Out (AppShell) waits for this before ending the session.
1240
+ useEffect(() => registerComposeFlush(flushPendingSave), []);
1241
+
1242
+ // Leaving the page (reload, closing the tab, following a link) with anything not saved: start a save that's
1243
+ // waiting on the debounce straight away and ask the browser to confirm, so the last couple of seconds of
1244
+ // typing (or content that can't be saved as a draft, or failed to) aren't lost silently. Never while signing
1245
+ // out - that navigation must not be cancellable.
1246
+ useEffect(() => {
1247
+ function handleBeforeUnload(event: BeforeUnloadEvent) {
1248
+ if (finishedRef.current || isSigningOut()) {
1249
+ return;
1250
+ }
1251
+ const latest = latestRef.current;
1252
+ const unsaved =
1253
+ pendingSaveRef.current ||
1254
+ latest.saveStatus === "saving" ||
1255
+ (latest.hasUserContent && lastSavedRef.current !== `${latest.draft?.uid}:${latest.contentKey}`);
1256
+ if (!unsaved) {
1257
+ return;
1258
+ }
1259
+ void flushPendingSave();
1260
+ event.preventDefault();
1261
+ event.returnValue = "";
1262
+ }
1263
+ window.addEventListener("beforeunload", handleBeforeUnload);
1264
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
1265
+ }, []);
1266
+
1267
+ // Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
1268
+ // with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
1269
+ // window must not move the caret there again.
1270
+ const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending || !!resume;
1271
+ const fieldsFocusedRef = useRef(false);
1272
+ const bodyFocusedRef = useRef(false);
1273
+ const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
1274
+ const focusSubjectOnMount = !isReplyOrForward && !!initialTo && !fieldsFocusedRef.current;
1275
+ const focusBodyOnMount = isReplyOrForward && !bodyFocusedRef.current;
1276
+
1277
+ // The editor serializes the seeded body its own way (see `RichTextEditor`'s `onInitialized`). While the body is
1278
+ // still exactly what compose seeded, that serialization becomes the baseline - so an untouched reply or signature
1279
+ // isn't mistaken for an edit (and autosaved) just because the editor was focused or clicked.
1280
+ /** The quote has been added: unless the reader has typed something already, that is still exactly what compose seeded. */
1281
+ function handleQuoteAppended(normalized: string) {
1282
+ const latest = latestRef.current;
1283
+ if (latest.html === latest.seededHtml) {
1284
+ setHtml(normalized);
1285
+ setSeededHtml(normalized);
1286
+ }
1287
+ }
1288
+ function handleEditorInitialized(normalized: string) {
1289
+ markComposePhase(id, "editor");
1290
+ const latest = latestRef.current;
1291
+ if (latest.html === latest.seededHtml && normalized !== latest.html) {
1292
+ setHtml(normalized);
1293
+ setSeededHtml(normalized);
1294
+ }
1295
+ }
1296
+ useEffect(() => {
1297
+ if (!minimized) {
1298
+ fieldsFocusedRef.current = true;
1299
+ bodyFocusedRef.current ||= contentReady;
1300
+ }
1301
+ }, [minimized, contentReady]);
1302
+
1303
+ // Keyboard shortcuts, active while the focus is inside this window (`container`; the scope attribute on its root is what tells the
1304
+ // dispatcher the focus is in a compose window at all). They do what the Send and Close buttons and autosave do, under the same conditions -
1305
+ // a shortcut pressed while its button would be disabled is consumed and does nothing.
1306
+ // Send never waits for the encryption settings to load (fail open, see `encryptionRequirement.ts`); it only needs the draft to exist. Pressed
1307
+ // while an earlier press is still being handled it does nothing (`submit()` guards that itself).
1308
+ const canSend = !!draft && !closing && !waitingForUploads;
1309
+ useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
1310
+ useShortcut(
1311
+ SHORTCUTS.compose.saveDraft,
1312
+ () => {
1313
+ // Autosave's own conditions: never a draft that must not be stored as plaintext.
1314
+ if (draft && contentReady && !closing && !autosaveSuppressed && hasUserContent) {
1315
+ void saveDraftNow();
1316
+ }
1317
+ },
1318
+ { container: windowRef },
1319
+ );
1320
+ useShortcut(
1321
+ SHORTCUTS.compose.close,
1322
+ () => {
1323
+ if (!closing) {
1324
+ void handleClose();
1325
+ }
1326
+ },
1327
+ { container: windowRef },
1328
+ );
1329
+ useShortcut(SHORTCUTS.compose.create, () => openCompose({ mailboxUid }), { container: windowRef });
1330
+
1331
+ const title = subject.trim() || "New Message";
1332
+ const titleId = `compose-title-${id}`;
1333
+
1334
+ const discardModal = (
1335
+ <Modal open={closePrompt !== null} onClose={() => setClosePrompt(null)} title={closePrompt?.title ?? DISCARD_TITLE}>
1336
+ <p className="text-sm text-text-muted mb-4">{closePrompt?.message}</p>
1337
+ <div className="flex gap-3">
1338
+ <Button type="button" onClick={() => void discardNow()} disabled={closing} className="!w-auto">
1339
+ Discard
1340
+ </Button>
1341
+ <Button type="button" variant="secondary" onClick={() => setClosePrompt(null)} className="!w-auto">
1342
+ Keep editing
1343
+ </Button>
1344
+ </div>
1345
+ </Modal>
1346
+ );
1347
+
1348
+ if (minimized) {
1349
+ return (
1350
+ <>
1351
+ <div role="dialog" aria-label={title} className="w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal">
1352
+ <div
1353
+ className="h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer"
1354
+ onClick={onToggleMinimize}
1355
+ >
1356
+ <span className="text-sm font-medium truncate">{title}</span>
1357
+ <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
1358
+ <HeaderButton label="Restore" onClick={onToggleMinimize} icon={HiOutlineArrowsPointingOut} />
1359
+ <HeaderButton label="Discard draft" onClick={handleDiscard} icon={HiOutlineXMark} disabled={closing} />
1360
+ </div>
1361
+ </div>
1362
+ </div>
1363
+ {discardModal}
1364
+ </>
1365
+ );
1366
+ }
1367
+
1368
+ return (
1369
+ <>
1370
+ <div
1371
+ ref={windowRef}
1372
+ role="dialog"
1373
+ data-shortcut-scope="compose"
1374
+ aria-labelledby={titleId}
1375
+ style={!isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
1376
+ className={[
1377
+ "shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1378
+ // `fixed` and `relative` may not both be on the element: Tailwind emits `.relative` after `.fixed`, so it wins and the
1379
+ // sheet sat in the flow of the little fixed container the windows are stacked in instead of covering the screen.
1380
+ isMobile
1381
+ ? "fixed inset-0 w-full h-full rounded-none border-0"
1382
+ : ["relative border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1383
+ ].join(" ")}
1384
+ >
1385
+ {!isMobile && (
1386
+ <>
1387
+ <div
1388
+ onPointerDown={handleResizeStart("top")}
1389
+ aria-hidden="true"
1390
+ className="absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20"
1391
+ />
1392
+ <div
1393
+ onPointerDown={handleResizeStart("left")}
1394
+ aria-hidden="true"
1395
+ className="absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20"
1396
+ />
1397
+ <div
1398
+ onPointerDown={handleResizeStart("corner")}
1399
+ role="separator"
1400
+ aria-label="Resize"
1401
+ className="absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30"
1402
+ />
1403
+ </>
1404
+ )}
1405
+
1406
+ <div
1407
+ className="h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer"
1408
+ onClick={onToggleMinimize}
1409
+ >
1410
+ <span id={titleId} className="text-sm font-medium truncate">
1411
+ {title}
1412
+ </span>
1413
+ <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
1414
+ <HeaderButton label="Minimize" onClick={onToggleMinimize} icon={HiOutlineMinus} />
1415
+ {!isMobile && (
1416
+ <HeaderButton
1417
+ label={expanded ? "Collapse" : "Expand"}
1418
+ onClick={toggleExpanded}
1419
+ icon={expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut}
1420
+ />
1421
+ )}
1422
+ <HeaderButton label="Close" onClick={() => void handleClose()} icon={HiOutlineXMark} disabled={closing} shortcut={SHORTCUTS.compose.close} />
1423
+ </div>
1424
+ </div>
1425
+
1426
+ <div className="flex-1 min-h-0 flex flex-col">
1427
+ {(folderError || draftError || sendError || attachError || discardError) && (
1428
+ // Scrolls inside the window when it is tall, instead of pushing the editor and the Send button out of the window's
1429
+ // fixed height, where they can't be reached.
1430
+ <div className="px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto">
1431
+ {folderError && <Alert>{folderError}</Alert>}
1432
+ {draftError && <Alert>{draftError}</Alert>}
1433
+ {sendError && <Alert>{sendError}</Alert>}
1434
+ {attachError && <Alert>{attachError}</Alert>}
1435
+ {discardError && <Alert>{discardError}</Alert>}
1436
+ </div>
1437
+ )}
1438
+
1439
+ {securityBlock && (
1440
+ <div className="px-3 pt-2">
1441
+ <Alert>
1442
+ <p className="mb-2">{securityBlock.message}</p>
1443
+ <Button type="button" onClick={() => void submit(true, blockedScheduleIso)}>
1444
+ {securityBlock.overrideLabel}
1445
+ </Button>
1446
+ </Alert>
1447
+ </div>
1448
+ )}
1449
+
1450
+ {fromOptions.length > 1 && (
1451
+ <div className={FIELD_ROW}>
1452
+ <label htmlFor={`compose-from-${id}`} className="text-xs text-text-muted shrink-0">
1453
+ From
1454
+ </label>
1455
+ <select
1456
+ id={`compose-from-${id}`}
1457
+ className={`${FIELD_INPUT} disabled:opacity-55`}
1458
+ // Two or more options only exist once the mailboxes have loaded, and the same answer that lets them list
1459
+ // (one that isn't known to be view-only) is what picks the default sender, so a sender is always chosen by now.
1460
+ value={mailboxUid}
1461
+ disabled={hasUploads}
1462
+ title={hasUploads ? "The sender can't be changed after adding attachments or images." : undefined}
1463
+ onChange={(e) => handleFromChange(e.target.value)}
1464
+ >
1465
+ {fromOptions.map((mb) => (
1466
+ <option key={mb.uid} value={mb.uid}>
1467
+ {mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress}
1468
+ {mb.ownerUserUid ? "" : " (shared)"}
1469
+ </option>
1470
+ ))}
1471
+ </select>
1472
+ </div>
1473
+ )}
1474
+
1475
+ <div className={FIELD_ROW}>
1476
+ <label htmlFor={`compose-to-${id}`} className="text-xs text-text-muted shrink-0">
1477
+ To
1478
+ </label>
1479
+ <RecipientInput
1480
+ id={`compose-to-${id}`}
1481
+ label="To"
1482
+ value={to}
1483
+ mailboxUid={mailboxUid}
1484
+ onChange={setTo}
1485
+ onBlur={(value) => checkRecipientDiscovery(parseAddresses(value))}
1486
+ onCommit={(value) => checkRecipientDiscovery(parseAddresses(value))}
1487
+ autoFocus={focusToOnMount}
1488
+ />
1489
+ {!showCcBcc && (
1490
+ <button
1491
+ type="button"
1492
+ onClick={() => setShowCcBcc(true)}
1493
+ className="text-xs text-text-muted hover:text-text shrink-0"
1494
+ >
1495
+ Cc Bcc
1496
+ </button>
1497
+ )}
1498
+ </div>
1499
+
1500
+ {showCcBcc && (
1501
+ <>
1502
+ <div className={FIELD_ROW}>
1503
+ <label htmlFor={`compose-cc-${id}`} className="text-xs text-text-muted shrink-0">
1504
+ Cc
1505
+ </label>
1506
+ <RecipientInput
1507
+ id={`compose-cc-${id}`}
1508
+ label="Cc"
1509
+ value={cc}
1510
+ mailboxUid={mailboxUid}
1511
+ onChange={setCc}
1512
+ onBlur={(value) => checkRecipientDiscovery(parseAddresses(value))}
1513
+ onCommit={(value) => checkRecipientDiscovery(parseAddresses(value))}
1514
+ />
1515
+ </div>
1516
+ <div className={FIELD_ROW}>
1517
+ <label htmlFor={`compose-bcc-${id}`} className="text-xs text-text-muted shrink-0">
1518
+ Bcc
1519
+ </label>
1520
+ <RecipientInput
1521
+ id={`compose-bcc-${id}`}
1522
+ label="Bcc"
1523
+ value={bcc}
1524
+ mailboxUid={mailboxUid}
1525
+ onChange={setBcc}
1526
+ onBlur={(value) => checkRecipientDiscovery(parseAddresses(value))}
1527
+ onCommit={(value) => checkRecipientDiscovery(parseAddresses(value))}
1528
+ />
1529
+ </div>
1530
+ </>
1531
+ )}
1532
+
1533
+ {(() => {
1534
+ // De-duplicated by the same normalized key `recipientStatuses` itself is keyed by - two
1535
+ // fields (or one field twice) naming the same mailbox in different casing now resolve to
1536
+ // one shared status object (see `addressCacheKey()`'s own doc comment), so without this
1537
+ // the list below would render - and React would warn about - two `<li key=...>`s sharing
1538
+ // the exact same key.
1539
+ const seenKeys = new Set<string>();
1540
+ const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
1541
+ .filter((r) => {
1542
+ const key = addressCacheKey(r.address);
1543
+ if (seenKeys.has(key)) {
1544
+ return false;
1545
+ }
1546
+ seenKeys.add(key);
1547
+ return true;
1548
+ })
1549
+ .map((r) => recipientStatuses[addressCacheKey(r.address)])
1550
+ .filter((status): status is RecipientEncryptionStatus => !!status);
1551
+ if (knownRecipients.length === 0) {
1552
+ return null;
1553
+ }
1554
+ return (
1555
+ <ul className="flex flex-wrap gap-1.5 px-3 pt-1.5" aria-label="Recipient encryption availability">
1556
+ {knownRecipients.map((status) => (
1557
+ <li
1558
+ key={status.address}
1559
+ className={`text-xs font-medium py-0.5 px-2 rounded-pill ${
1560
+ status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"
1561
+ }`}
1562
+ >
1563
+ {status.address} {status.canEncrypt ? "supports encryption" : "no encryption key found"}
1564
+ </li>
1565
+ ))}
1566
+ </ul>
1567
+ );
1568
+ })()}
1569
+
1570
+ <div className={FIELD_ROW}>
1571
+ <input
1572
+ aria-label="Subject"
1573
+ type="text"
1574
+ placeholder="Subject"
1575
+ className={FIELD_INPUT}
1576
+ value={subject}
1577
+ autoFocus={focusSubjectOnMount}
1578
+ onChange={(e) => setSubject(e.target.value)}
1579
+ />
1580
+ </div>
1581
+
1582
+ <div className="flex-1 min-h-0 p-2">
1583
+ {contentReady && (
1584
+ <RichTextEditor
1585
+ appendHtml={lateQuoteHtml}
1586
+ onAppended={handleQuoteAppended}
1587
+ value={html}
1588
+ onChange={setHtml}
1589
+ fill
1590
+ onUploadImage={handleUploadImage}
1591
+ autoFocusStart={focusBodyOnMount}
1592
+ onInitialized={handleEditorInitialized}
1593
+ />
1594
+ )}
1595
+ </div>
1596
+
1597
+ {quotePending && (
1598
+ <p role="status" className="px-3 pb-1 text-xs text-text-muted">
1599
+ Loading the original message&hellip;
1600
+ </p>
1601
+ )}
1602
+
1603
+ {attachments.length > 0 && (
1604
+ <ul className="flex flex-wrap gap-2 px-3 pb-2">
1605
+ {attachments.map((attachment) => (
1606
+ <li key={attachment.uid} className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted">
1607
+ {attachment.filename}
1608
+ </li>
1609
+ ))}
1610
+ </ul>
1611
+ )}
1612
+
1613
+ <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
1614
+ <input type="checkbox" checked={requestReceipt} onChange={(e) => setRequestReceipt(e.target.checked)} />
1615
+ Request a read receipt
1616
+ </label>
1617
+
1618
+ {needsUnlockForCrypto && (
1619
+ <div className="px-3 pb-1">
1620
+ <button
1621
+ type="button"
1622
+ onClick={handleUnlockForCrypto}
1623
+ className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
1624
+ >
1625
+ <HiOutlineLockClosed size={12} aria-hidden="true" />
1626
+ Unlock to sign or encrypt this message
1627
+ </button>
1628
+ </div>
1629
+ )}
1630
+
1631
+ {/* Only rendered once this session actually has a usable signing/encryption key - see
1632
+ `sendJob.ts`'s signing path for why signing rarely shows today (no signing
1633
+ certificate exists until RFC 8823 ACME enrollment lands). */}
1634
+ {unlockedKeys?.signingPrivateKey && (
1635
+ <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
1636
+ <input type="checkbox" checked={signEnabled} onChange={(e) => setSignEnabled(e.target.checked)} />
1637
+ Digitally sign this message
1638
+ </label>
1639
+ )}
1640
+ {unlockedKeys?.encryptionPrivateKey && (
1641
+ <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
1642
+ <input type="checkbox" checked={encryptRequested} onChange={(e) => setEncryptRequested(e.target.checked)} />
1643
+ Encrypt this message
1644
+ </label>
1645
+ )}
1646
+
1647
+ <div className="shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border">
1648
+ <div className="flex items-center rounded-pill bg-primary text-white overflow-hidden">
1649
+ <button
1650
+ type="button"
1651
+ onClick={() => void submit(false)}
1652
+ title={withHint("Send", SHORTCUTS.compose.send, keyEnv)}
1653
+ aria-keyshortcuts={ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv)}
1654
+ disabled={!draft || closing || waitingForUploads}
1655
+ className="py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
1656
+ >
1657
+ Send
1658
+ </button>
1659
+ <button
1660
+ ref={scheduleButtonRef}
1661
+ type="button"
1662
+ aria-label="Send later"
1663
+ aria-haspopup="true"
1664
+ aria-expanded={schedulePickerOpen}
1665
+ disabled={!draft || closing || waitingForUploads}
1666
+ onClick={() => setSchedulePickerOpen((o) => !o)}
1667
+ className="py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
1668
+ >
1669
+ <HiOutlineChevronDown size={14} />
1670
+ </button>
1671
+ </div>
1672
+ {schedulePickerOpen && (
1673
+ <ScheduleSendPicker
1674
+ anchorRef={scheduleButtonRef}
1675
+ onClose={() => setSchedulePickerOpen(false)}
1676
+ onSchedule={handleScheduleSend}
1677
+ />
1678
+ )}
1679
+
1680
+ <label
1681
+ aria-label="Attach files"
1682
+ title="Attach files"
1683
+ className="w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed"
1684
+ >
1685
+ <HiOutlinePaperClip size={18} />
1686
+ <input type="file" multiple disabled={!draft} onChange={handleFilesSelected} className="sr-only" />
1687
+ </label>
1688
+
1689
+ <span role="status" aria-live="polite" className="ml-auto text-xs text-text-muted">
1690
+ {waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus]}
1691
+ </span>
1692
+
1693
+ <button
1694
+ type="button"
1695
+ aria-label="Discard draft"
1696
+ title="Discard draft"
1697
+ onClick={handleDiscard}
1698
+ disabled={closing}
1699
+ className="w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed"
1700
+ >
1701
+ <HiOutlineTrash size={18} />
1702
+ </button>
1703
+ </div>
1704
+ </div>
1705
+ </div>
1706
+ {discardModal}
1707
+ </>
1708
+ );
1709
+ }