@rapidmx/web-client 0.4.0 → 0.6.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 (320) hide show
  1. package/README.md +88 -0
  2. package/apps/admin/audit-log/index.tsx +35 -8
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  6. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  7. package/apps/admin/index.tsx +19 -1
  8. package/apps/admin/ingest-queue/index.tsx +44 -5
  9. package/apps/admin/mailboxes/[uid].tsx +211 -184
  10. package/apps/admin/quarantine/index.tsx +46 -14
  11. package/apps/admin/transport-rules/[uid].tsx +30 -1
  12. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  13. package/apps/admin/transport-rules/index.tsx +24 -6
  14. package/apps/admin/transport-rules/new/index.tsx +30 -1
  15. package/apps/escrow/_layout.tsx +2 -3
  16. package/apps/escrow/audit-log/index.tsx +196 -168
  17. package/apps/escrow/matters/[uid].tsx +621 -521
  18. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  19. package/apps/shared/components/admin/layout/AdminShell.tsx +28 -9
  20. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  21. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  22. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  23. package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
  24. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
  25. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
  26. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
  27. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
  28. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
  29. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
  30. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
  31. package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
  32. package/apps/shared/components/admin/signOut.ts +79 -0
  33. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  34. package/apps/shared/components/calendar/EventModal.tsx +717 -549
  35. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  36. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  37. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  38. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  39. package/apps/shared/components/calendar/allDay.ts +124 -0
  40. package/apps/shared/components/calendar/layout/CalendarShell.tsx +194 -192
  41. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  42. package/apps/shared/components/contacts/ContactForm.tsx +377 -348
  43. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  44. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  45. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  46. package/apps/shared/components/contacts/layout/ContactsShell.tsx +199 -197
  47. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  48. package/apps/shared/components/layout/AppShell.tsx +123 -33
  49. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  50. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
  51. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  52. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  53. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
  54. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  55. package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
  56. package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
  57. package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
  58. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  59. package/apps/shared/components/mail/layout/MailShell.tsx +420 -408
  60. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  61. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  62. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  63. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  64. package/apps/shared/components/settings/layout/SettingsShell.tsx +23 -9
  65. package/apps/shared/components/tasks/layout/TasksShell.tsx +201 -199
  66. package/apps/shared/mail/listAllPages.ts +39 -0
  67. package/apps/shared/plugins/pluginNav.ts +73 -0
  68. package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
  69. package/apps/shared/search/localIndexBuilder.ts +481 -179
  70. package/apps/shared/search/localIndexRpcClient.ts +231 -29
  71. package/apps/shared/search/localIndexSchema.ts +15 -6
  72. package/apps/shared/search/localIndexStorage.ts +98 -0
  73. package/apps/shared/search/localIndexVFS.ts +32 -1
  74. package/apps/shared/search/localIndexWorker.ts +648 -186
  75. package/apps/www/calendar/index.tsx +438 -417
  76. package/apps/www/contacts/[uid].tsx +21 -2
  77. package/apps/www/contacts/index.tsx +116 -12
  78. package/apps/www/index.tsx +1314 -1041
  79. package/apps/www/messages/[uid].tsx +18 -6
  80. package/apps/www/settings/auto-reply/index.tsx +134 -129
  81. package/apps/www/settings/encryption/index.tsx +739 -216
  82. package/apps/www/settings/filters/[uid].tsx +171 -155
  83. package/apps/www/settings/filters/new/index.tsx +8 -1
  84. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  85. package/apps/www/settings/privacy/index.tsx +494 -436
  86. package/apps/www/settings/read-receipts/index.tsx +148 -125
  87. package/apps/www/settings/sharing/index.tsx +17 -14
  88. package/apps/www/tasks/index.tsx +638 -579
  89. package/dist/apps/admin/_404.d.ts +2 -0
  90. package/dist/apps/admin/_500.d.ts +2 -0
  91. package/dist/apps/admin/_layout.d.ts +8 -0
  92. package/dist/apps/admin/audit-log/index.d.ts +11 -0
  93. package/dist/apps/admin/audit-log/index.js +38 -8
  94. package/dist/apps/admin/branding/index.d.ts +3 -0
  95. package/dist/apps/admin/data-requests/index.d.ts +3 -0
  96. package/dist/apps/admin/data-requests/index.js +52 -72
  97. package/dist/apps/admin/distribution-lists/[uid].d.ts +7 -0
  98. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  99. package/dist/apps/admin/distribution-lists/index.d.ts +3 -0
  100. package/dist/apps/admin/distribution-lists/new/index.d.ts +3 -0
  101. package/dist/apps/admin/domains/[uid].d.ts +7 -0
  102. package/dist/apps/admin/domains/index.d.ts +3 -0
  103. package/dist/apps/admin/domains/new/index.d.ts +3 -0
  104. package/dist/apps/admin/encryption-policy/index.d.ts +3 -0
  105. package/dist/apps/admin/escrow-scopes/[uid].d.ts +21 -0
  106. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  107. package/dist/apps/admin/escrow-scopes/index.d.ts +3 -0
  108. package/dist/apps/admin/escrow-scopes/new/index.d.ts +3 -0
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.d.ts +3 -0
  111. package/dist/apps/admin/index.js +4 -1
  112. package/dist/apps/admin/ingest-queue/index.d.ts +4 -0
  113. package/dist/apps/admin/ingest-queue/index.js +24 -6
  114. package/dist/apps/admin/mailbox-policy/index.d.ts +3 -0
  115. package/dist/apps/admin/mailboxes/[uid].d.ts +7 -0
  116. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  117. package/dist/apps/admin/mailboxes/new/index.d.ts +3 -0
  118. package/dist/apps/admin/plugins/index.d.ts +3 -0
  119. package/dist/apps/admin/quarantine/index.d.ts +6 -0
  120. package/dist/apps/admin/quarantine/index.js +20 -11
  121. package/dist/apps/admin/retention-policy/index.d.ts +3 -0
  122. package/dist/apps/admin/setup/index.d.ts +3 -0
  123. package/dist/apps/admin/transport-rules/[uid].d.ts +7 -0
  124. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  125. package/dist/apps/admin/transport-rules/_transportRuleConfig.d.ts +21 -0
  126. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  127. package/dist/apps/admin/transport-rules/index.d.ts +3 -0
  128. package/dist/apps/admin/transport-rules/index.js +22 -6
  129. package/dist/apps/admin/transport-rules/new/index.d.ts +3 -0
  130. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  131. package/dist/apps/escrow/_layout.d.ts +10 -0
  132. package/dist/apps/escrow/_layout.js +3 -3
  133. package/dist/apps/escrow/audit-log/index.d.ts +9 -0
  134. package/dist/apps/escrow/audit-log/index.js +26 -1
  135. package/dist/apps/escrow/index.d.ts +3 -0
  136. package/dist/apps/escrow/matters/[uid].d.ts +7 -0
  137. package/dist/apps/escrow/matters/[uid].js +77 -42
  138. package/dist/apps/escrow/matters/new/index.d.ts +3 -0
  139. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.d.ts +17 -0
  140. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +32 -0
  141. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  142. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +32 -0
  143. package/dist/apps/shared/components/admin/layout/AdminShell.js +15 -6
  144. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.d.ts +12 -0
  145. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  146. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.d.ts +6 -0
  147. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.d.ts +19 -0
  148. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  149. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +12 -0
  150. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  151. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +9 -0
  152. package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
  153. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +12 -0
  154. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
  155. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +12 -0
  156. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
  157. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.d.ts +9 -0
  158. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.d.ts +19 -0
  159. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
  160. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +10 -0
  161. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
  162. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +4 -0
  163. package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
  164. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +9 -0
  165. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
  166. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.d.ts +14 -0
  167. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
  168. package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +36 -0
  169. package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
  170. package/dist/apps/shared/components/admin/signOut.d.ts +23 -0
  171. package/dist/apps/shared/components/admin/signOut.js +75 -0
  172. package/dist/apps/shared/components/admin/usePagedList.d.ts +39 -0
  173. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  174. package/dist/apps/shared/components/calendar/CalendarListSidebar.d.ts +26 -0
  175. package/dist/apps/shared/components/calendar/EventModal.d.ts +46 -0
  176. package/dist/apps/shared/components/calendar/EventModal.js +142 -21
  177. package/dist/apps/shared/components/calendar/MonthView.d.ts +15 -0
  178. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  179. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +20 -0
  180. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  181. package/dist/apps/shared/components/calendar/ResourcePicker.d.ts +19 -0
  182. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +28 -0
  183. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  184. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +17 -0
  185. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  186. package/dist/apps/shared/components/calendar/allDay.d.ts +51 -0
  187. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  188. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +40 -0
  189. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -3
  190. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +23 -0
  191. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  192. package/dist/apps/shared/components/contacts/ContactForm.d.ts +20 -0
  193. package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
  194. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +40 -0
  195. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  196. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +25 -0
  197. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +40 -0
  198. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  199. package/dist/apps/shared/components/contacts/contactKeys.d.ts +29 -0
  200. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  201. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +21 -0
  202. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
  203. package/dist/apps/shared/components/escrow/layout/EscrowShell.d.ts +25 -0
  204. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  205. package/dist/apps/shared/components/forms/StringListField.d.ts +22 -0
  206. package/dist/apps/shared/components/layout/AppShell.d.ts +49 -0
  207. package/dist/apps/shared/components/layout/AppShell.js +97 -26
  208. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +18 -0
  209. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  210. package/dist/apps/shared/components/layout/KeyEnrollmentGate.d.ts +51 -0
  211. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
  212. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +13 -0
  213. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  214. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +66 -0
  215. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  216. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +45 -0
  217. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
  218. package/dist/apps/shared/components/layout/UserMenu.d.ts +23 -0
  219. package/dist/apps/shared/components/mail/ConversationList.d.ts +11 -0
  220. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +23 -0
  221. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  222. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +93 -0
  223. package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
  224. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +58 -0
  225. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  226. package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +15 -0
  227. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +37 -0
  228. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
  229. package/dist/apps/shared/components/mail/compose/EmojiPicker.d.ts +14 -0
  230. package/dist/apps/shared/components/mail/compose/GifPicker.d.ts +13 -0
  231. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +34 -0
  232. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.d.ts +16 -0
  233. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +27 -0
  234. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  235. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +57 -0
  236. package/dist/apps/shared/components/mail/layout/MailShell.js +13 -4
  237. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +33 -0
  238. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  239. package/dist/apps/shared/components/mail/verificationSeals.d.ts +22 -0
  240. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  241. package/dist/apps/shared/components/mail/writableMailboxes.d.ts +15 -0
  242. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  243. package/dist/apps/shared/components/rules/RuleBuilder.d.ts +70 -0
  244. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  245. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +41 -0
  246. package/dist/apps/shared/components/settings/layout/SettingsShell.js +19 -9
  247. package/dist/apps/shared/components/tasks/TasksSidebar.d.ts +41 -0
  248. package/dist/apps/shared/components/tasks/TasksToolbar.d.ts +14 -0
  249. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +24 -0
  250. package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
  251. package/dist/apps/shared/mail/findWellKnownFolderUid.d.ts +5 -0
  252. package/dist/apps/shared/mail/listAllPages.d.ts +17 -0
  253. package/dist/apps/shared/mail/listAllPages.js +25 -0
  254. package/dist/apps/shared/plugins/pluginNav.d.ts +39 -0
  255. package/dist/apps/shared/plugins/pluginNav.js +33 -0
  256. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +13 -0
  257. package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
  258. package/dist/apps/shared/search/localIndexBlockCipher.d.ts +36 -0
  259. package/dist/apps/shared/search/localIndexBuilder.d.ts +85 -0
  260. package/dist/apps/shared/search/localIndexBuilder.js +292 -37
  261. package/dist/apps/shared/search/localIndexKey.d.ts +4 -0
  262. package/dist/apps/shared/search/localIndexRpcClient.d.ts +71 -0
  263. package/dist/apps/shared/search/localIndexRpcClient.js +191 -26
  264. package/dist/apps/shared/search/localIndexSchema.d.ts +111 -0
  265. package/dist/apps/shared/search/localIndexSchema.js +12 -6
  266. package/dist/apps/shared/search/localIndexSizePreference.d.ts +27 -0
  267. package/dist/apps/shared/search/localIndexStorage.d.ts +28 -0
  268. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  269. package/dist/apps/shared/search/localIndexVFS.d.ts +101 -0
  270. package/dist/apps/shared/search/localIndexVFS.js +68 -40
  271. package/dist/apps/shared/search/localIndexWorker.d.ts +145 -0
  272. package/dist/apps/shared/search/localIndexWorker.js +519 -173
  273. package/dist/apps/shared/search/searchTier2.d.ts +30 -0
  274. package/dist/apps/www/_404.d.ts +2 -0
  275. package/dist/apps/www/_500.d.ts +2 -0
  276. package/dist/apps/www/_layout.d.ts +14 -0
  277. package/dist/apps/www/calendar/index.d.ts +3 -0
  278. package/dist/apps/www/calendar/index.js +28 -10
  279. package/dist/apps/www/contacts/[uid].d.ts +13 -0
  280. package/dist/apps/www/contacts/[uid].js +13 -2
  281. package/dist/apps/www/contacts/index.d.ts +3 -0
  282. package/dist/apps/www/contacts/index.js +80 -10
  283. package/dist/apps/www/index.d.ts +3 -0
  284. package/dist/apps/www/index.js +339 -108
  285. package/dist/apps/www/messages/[uid].d.ts +11 -0
  286. package/dist/apps/www/messages/[uid].js +18 -6
  287. package/dist/apps/www/settings/auto-reply/index.d.ts +4 -0
  288. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  289. package/dist/apps/www/settings/encryption/index.d.ts +7 -0
  290. package/dist/apps/www/settings/encryption/index.js +514 -97
  291. package/dist/apps/www/settings/filters/[uid].d.ts +8 -0
  292. package/dist/apps/www/settings/filters/[uid].js +22 -7
  293. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.d.ts +11 -0
  294. package/dist/apps/www/settings/filters/index.d.ts +4 -0
  295. package/dist/apps/www/settings/filters/new/index.d.ts +4 -0
  296. package/dist/apps/www/settings/filters/new/index.js +8 -2
  297. package/dist/apps/www/settings/focused-inbox/index.d.ts +4 -0
  298. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  299. package/dist/apps/www/settings/labels/index.d.ts +4 -0
  300. package/dist/apps/www/settings/privacy/index.d.ts +4 -0
  301. package/dist/apps/www/settings/privacy/index.js +57 -22
  302. package/dist/apps/www/settings/read-receipts/index.d.ts +4 -0
  303. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  304. package/dist/apps/www/settings/sharing/index.d.ts +4 -0
  305. package/dist/apps/www/settings/sharing/index.js +7 -11
  306. package/dist/apps/www/settings/signatures/[uid].d.ts +8 -0
  307. package/dist/apps/www/settings/signatures/index.d.ts +4 -0
  308. package/dist/apps/www/settings/signatures/new/index.d.ts +4 -0
  309. package/dist/apps/www/settings/signatures/signatureDefaults.d.ts +10 -0
  310. package/dist/apps/www/tasks/index.d.ts +3 -0
  311. package/dist/apps/www/tasks/index.js +50 -10
  312. package/package.json +3 -3
  313. package/apps/shared/components/booking/AvailabilityEditor.tsx +0 -118
  314. package/apps/www/settings/booking-types/[uid].tsx +0 -300
  315. package/apps/www/settings/booking-types/index.tsx +0 -98
  316. package/apps/www/settings/booking-types/new/index.tsx +0 -212
  317. package/dist/apps/shared/components/booking/AvailabilityEditor.js +0 -45
  318. package/dist/apps/www/settings/booking-types/[uid].js +0 -129
  319. package/dist/apps/www/settings/booking-types/index.js +0 -29
  320. package/dist/apps/www/settings/booking-types/new/index.js +0 -86
@@ -25,26 +25,29 @@ import {
25
25
  createDraft,
26
26
  deleteMessage,
27
27
  getMailbox,
28
+ getMessage,
28
29
  listFolders,
29
30
  listMailboxes,
30
31
  sendMessage,
31
32
  setMessageRequestReceipt,
32
- setMessageScheduledSendTime,
33
33
  uploadAttachment,
34
34
  } from "@rapidmx/react-shared/mail/mailApi.js";
35
35
  import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
36
+ import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
36
37
  import { decideMessageEncryption, resolveRecipientEncryption, RecipientEncryptionStatus } from "@rapidmx/react-shared/crypto/composeSecurity.js";
37
- import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
38
+ import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
38
39
  import { EncryptionPolicy, findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
39
40
  import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
40
41
  import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
41
42
  import { ProtectedHeaders, applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
42
43
  import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
43
44
  import type { ComposeSession } from "./ComposeContext.js";
45
+ import { isSigningOut, registerComposeFlush } from "./composeFlushRegistry.js";
44
46
  import RichTextEditor from "./RichTextEditor.js";
45
47
  import ScheduleSendPicker from "./ScheduleSendPicker.js";
46
48
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
47
49
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
50
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
48
51
 
49
52
  export interface ComposeWindowProps {
50
53
  session: ComposeSession;
@@ -53,6 +56,77 @@ export interface ComposeWindowProps {
53
56
  /** The signed-in user - identifies their own ("primary") mailbox, the default From when the session
54
57
  * doesn't name a mailbox. */
55
58
  userUid?: string;
59
+ /** The caller holds a trusted (admin) role - every mailbox is writable, so no per-mailbox access checks. */
60
+ trusted?: boolean;
61
+ /** How long (ms) after the last edit the draft is autosaved. Defaults to `DEFAULT_AUTOSAVE_DELAY_MS`;
62
+ * only overridden by tests. */
63
+ autosaveDelayMs?: number;
64
+ /** Backoff (ms) between automatic retries of a failed mailbox/encryption-policy load, one entry per retry.
65
+ * Defaults to `DEFAULT_CRYPTO_RETRY_DELAYS_MS`; only overridden by tests. */
66
+ cryptoRetryDelaysMs?: number[];
67
+ }
68
+
69
+ /** Debounce between the last edit and the draft autosave. */
70
+ export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
71
+
72
+ /** Automatic retries of a failed mailbox/encryption-policy load; after the last one only "Retry" tries again. */
73
+ export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000];
74
+
75
+ /** Signed/encrypted bodies are built client-side from the editor's HTML verbatim, so an inline image
76
+ * (an uploaded attachment previewed via its content URL, or a `cid:` reference) would never be carried -
77
+ * only `assembleDraft()`'s server-side plaintext path rewrites those into real MIME parts. */
78
+ const INLINE_IMAGE_PATTERN = /<img\b[^>]*\bsrc\s*=\s*["']?(?:cid:|[^"'\s>]*\/mail\/attachments\/[^"'\s>]+\/content)/i;
79
+
80
+ const KEYS_LOCKED_SIGN_MESSAGE =
81
+ "This message can't be signed right now - your signing key is locked or your mailbox details couldn't be loaded. Unlock and send again, or send it without signing.";
82
+ const KEYS_LOCKED_ENCRYPT_MESSAGE =
83
+ "This message can't be encrypted right now - your encryption key is locked. Unlock and send again, or send it without encryption.";
84
+ const POLICY_UNAVAILABLE_MESSAGE =
85
+ "Your encryption settings couldn't be loaded, so this message can't be encrypted right now. Try again, or send it without encryption.";
86
+ const BCC_ENCRYPTED_MESSAGE = "Bcc recipients can't be used with encrypted messages. Remove Bcc recipients or turn off encryption.";
87
+ const LOOKUP_UNAVAILABLE_MESSAGE =
88
+ "Your recipients' encryption keys couldn't be checked, so this message might go out unencrypted when it should be encrypted. Try again, or send it without encryption.";
89
+ /** Thrown by `deleteDraft()` when the server copy is no longer this window's draft (sent or scheduled elsewhere). */
90
+ const NO_LONGER_A_DRAFT_MESSAGE = "This message was already sent or scheduled from another window, so it wasn't deleted.";
91
+
92
+ /** The server refuses signed/encrypted mail whose From display name differs from its own safe form, which drops a
93
+ * name containing `@` (or a look-alike) or a line break. */
94
+ const UNSAFE_DISPLAY_NAME_PATTERN = /[@@﹫\r\n]/;
95
+
96
+ /** Whether `fresh` (a re-read of a draft) is still an unsent draft in `folderUid`: never delete a message another
97
+ * window has since sent, scheduled, or started sending. */
98
+ function isStillDraft(fresh: Message, folderUid: string): boolean {
99
+ return fresh.folderUid === folderUid && !fresh.scheduledSendTime && !fresh.scheduledSendLeaseExpiresAt && !fresh.scheduledSendRelayedAt;
100
+ }
101
+
102
+ /** Any tier of `policy` auto-encrypts - only then can a message with recipients still to be added turn out encrypted
103
+ * without the user asking. */
104
+ function policyCanAutoEncrypt(policy: EncryptionPolicy): boolean {
105
+ return [policy.encryptSameOrg, policy.encryptFederated, policy.encryptExternal].includes("automatic");
106
+ }
107
+
108
+ const SAVE_STATUS_LABEL = { idle: "", saving: "Saving…", saved: "Draft saved", error: "Couldn't save draft" } as const;
109
+
110
+ const DISCARD_TITLE = "Discard this draft?";
111
+ const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so closing this window discards what you've written.";
112
+ const CHECKING_CLOSE_MESSAGE =
113
+ "This message may be encrypted, and that's still being checked, so it can't be saved as a draft yet. Keep editing and close again in a moment, or discard it.";
114
+ const CRYPTO_UNAVAILABLE_MESSAGE =
115
+ "Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
116
+ const NO_RECIPIENTS_CLOSE_MESSAGE =
117
+ "This message may be encrypted once its recipients are known, so it isn't saved as a draft until you add them. Add a recipient and close again, or discard it.";
118
+
119
+ /** The confirmation shown before a Close/Discard throws content away (or when a Close couldn't save it). */
120
+ interface ClosePrompt {
121
+ title: string;
122
+ message: string;
123
+ /** Also offer "Retry" for loading the encryption settings. */
124
+ retry?: boolean;
125
+ }
126
+
127
+ interface SecurityBlock {
128
+ message: string;
129
+ overrideLabel: string;
56
130
  }
57
131
 
58
132
  const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
@@ -83,14 +157,25 @@ function parseAddresses(value: string): ComposeRecipientInput[] {
83
157
  .map((address) => ({ address }));
84
158
  }
85
159
 
86
- function HeaderButton({ label, onClick, icon: Icon }: { label: string; onClick: () => void; icon: React.ComponentType<{ size?: number }> }) {
160
+ function HeaderButton({
161
+ label,
162
+ onClick,
163
+ icon: Icon,
164
+ disabled,
165
+ }: {
166
+ label: string;
167
+ onClick: () => void;
168
+ icon: React.ComponentType<{ size?: number }>;
169
+ disabled?: boolean;
170
+ }) {
87
171
  return (
88
172
  <button
89
173
  type="button"
90
174
  aria-label={label}
91
175
  title={label}
92
176
  onClick={onClick}
93
- className="w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white"
177
+ disabled={disabled}
178
+ 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"
94
179
  >
95
180
  <Icon size={14} />
96
181
  </button>
@@ -111,7 +196,15 @@ function HeaderButton({ label, onClick, icon: Icon }: { label: string; onClick:
111
196
  * see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
112
197
  * once.
113
198
  */
114
- export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid }: ComposeWindowProps) {
199
+ export default function ComposeWindow({
200
+ session,
201
+ onClose,
202
+ onToggleMinimize,
203
+ userUid,
204
+ trusted,
205
+ autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS,
206
+ cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS,
207
+ }: ComposeWindowProps) {
115
208
  const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, suppressSigning, minimized } = session;
116
209
  // The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
117
210
  // compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
@@ -132,6 +225,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
132
225
  const [draftsFolderUid, setDraftsFolderUid] = useState<string | undefined>();
133
226
  const [folderError, setFolderError] = useState<string | null>(null);
134
227
  const [draft, setDraft] = useState<Message | null>(null);
228
+ // Drafts replaced by a From switch, awaiting deletion until their replacement has actually been created.
229
+ const supersededDraftsRef = useRef<Message[]>([]);
135
230
  const [draftError, setDraftError] = useState<string | null>(null);
136
231
  const [expanded, setExpanded] = useState(false);
137
232
  const [manualSize, setManualSize] = useState<Size | null>(null);
@@ -163,13 +258,58 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
163
258
  // just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
164
259
  // never cleared, so switching focus between To/Cc/Bcc repeatedly doesn't re-trigger lookups.
165
260
  const [recipientStatuses, setRecipientStatuses] = useState<Record<string, RecipientEncryptionStatus>>({});
261
+ // Addresses whose lookup is on the wire (so the blur handlers and the prefilled-recipient effect don't
262
+ // look one up twice), and a counter bumped by a From switch so a lookup for the previous sender is dropped.
263
+ const lookupsInFlightRef = useRef(new Set<string>());
264
+ const discoveryGenerationRef = useRef(0);
265
+ // A failed lookup stores no status (it would read as "no key" and let a message that must be encrypted autosave
266
+ // or send as plaintext): it's retried with the same backoff as the mailbox/policy load, and once those retries
267
+ // run out the address is "exhausted" - shown as a check that couldn't be done, with Retry.
268
+ const lookupFailuresRef = useRef(new Map<string, number>());
269
+ const lookupRetryTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
270
+ const [lookupRetryToken, setLookupRetryToken] = useState(0);
271
+ const [exhaustedLookups, setExhaustedLookups] = useState<Record<string, true>>({});
272
+ // A send refused because signing/encryption was wanted but can't happen right now (keys locked, the
273
+ // encryption policy/mailbox couldn't be loaded, or Bcc recipients on an encrypted message) - never a
274
+ // silent downgrade to plaintext; the user has to explicitly pick the override.
275
+ const [securityBlock, setSecurityBlock] = useState<SecurityBlock | null>(null);
276
+ // The schedule time of the send that got blocked (undefined for an immediate send), so the banners'
277
+ // override buttons replay the same kind of send the user originally asked for.
278
+ const [blockedScheduleIso, setBlockedScheduleIso] = useState<string | undefined>();
279
+ // Which crypto toggles were ever shown during this compose session (per sending mailbox). Keys that
280
+ // lock after that point must block the send rather than quietly dropping the signature/encryption.
281
+ const offeredCryptoRef = useRef({ sign: false, encrypt: false });
282
+
283
+ // Draft autosave / discard bookkeeping.
284
+ const [seededHtml, setSeededHtml] = useState<string | undefined>();
285
+ const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
286
+ const [closePrompt, setClosePrompt] = useState<ClosePrompt | null>(null);
287
+ /** A Close (saving) or Discard (deleting) is on the wire - the window closes once it succeeds. */
288
+ const [closing, setClosing] = useState(false);
289
+ const [discardError, setDiscardError] = useState<string | null>(null);
290
+ /** Why the last save failed, cleared by the next successful one. */
291
+ const saveErrorRef = useRef<string | null>(null);
292
+ /** `<draftUid>:<content key>` of the last successful save. */
293
+ const lastSavedRef = useRef<string | null>(null);
294
+ /** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
295
+ const pendingSaveRef = useRef(false);
296
+ /** Mirrors `sending` for the page-leave/sign-out flushes, which run outside React's render cycle. */
297
+ const sendingRef = useRef(false);
298
+ /** The latest save request, if any - each save is chained after the one before it. Always settles (never
299
+ * rejects), to the saved message, or `undefined` when that save failed. */
300
+ const saveInFlightRef = useRef<Promise<Message | undefined>>(Promise.resolve(undefined));
301
+ /** Set once the window has been sent/closed/discarded - nothing more gets autosaved after that. */
302
+ const finishedRef = useRef(false);
166
303
 
167
- // Best-effort, same as the signature-list fetch below: a mailbox with no keys enrolled yet (or a
168
- // failed fetch) just means sign/encrypt stay unavailable for this compose session, never a blocking
169
- // error - encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
304
+ // A mailbox with no keys enrolled yet just means sign/encrypt stay unavailable for this compose session -
305
+ // encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
170
306
  // `cryptoContextReady` (below) gates Send/Send-later until both calls have settled either way, so a
171
307
  // send that happens to race this fetch can't silently skip encryption the spec says should apply.
308
+ // A failed load is retried with backoff (`cryptoRetryDelaysMs`), then on demand ("Retry"): until both have
309
+ // loaded, whether this message must be encrypted is unknown, so it isn't autosaved (see `autosaveSuppressed`).
172
310
  const [cryptoContextReady, setCryptoContextReady] = useState(false);
311
+ const [cryptoLoadFailed, setCryptoLoadFailed] = useState(false);
312
+ const [cryptoRetryToken, setCryptoRetryToken] = useState(0);
173
313
 
174
314
  useEffect(() => {
175
315
  let cancelled = false;
@@ -179,7 +319,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
179
319
  return;
180
320
  }
181
321
  setMailboxes(result);
182
- setFromMailboxUid((current) => current ?? (result.find((mb) => mb.ownerUserUid === userUid) ?? result[0])?.uid);
322
+ // Never default to a mailbox already known to be view-only (see the effect below for one
323
+ // that turns out to be).
324
+ const candidates = result.filter((mb) => peekMailboxWritability(mb, userUid, trusted) !== false);
325
+ setFromMailboxUid((current) => current ?? (candidates.find((mb) => mb.ownerUserUid === userUid) ?? candidates[0])?.uid);
183
326
  })
184
327
  .catch((err) => {
185
328
  // Only fatal when there's no mailbox to fall back on - a session that already names one just
@@ -191,7 +334,6 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
191
334
  return () => {
192
335
  cancelled = true;
193
336
  };
194
- // eslint-disable-next-line react-hooks/exhaustive-deps
195
337
  }, []);
196
338
 
197
339
  useEffect(() => {
@@ -199,40 +341,52 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
199
341
  return;
200
342
  }
201
343
  let cancelled = false;
344
+ let timer: ReturnType<typeof setTimeout> | undefined;
345
+ // A retry only fetches what's still missing.
346
+ let mailboxLoaded = mailbox?.uid === mailboxUid;
347
+ let policyLoaded = !!encryptionPolicy;
348
+ let retries = 0;
202
349
  setCryptoContextReady(false);
203
- let mailboxSettled = false;
204
- let policySettled = false;
205
- function checkReady() {
206
- if (!cancelled && mailboxSettled && policySettled) {
207
- setCryptoContextReady(true);
350
+ setCryptoLoadFailed(false);
351
+ async function load() {
352
+ await Promise.all([
353
+ mailboxLoaded ||
354
+ getMailbox(mailboxUid!).then(
355
+ (result) => {
356
+ mailboxLoaded = true;
357
+ if (!cancelled) {
358
+ setMailbox(result);
359
+ }
360
+ },
361
+ () => undefined,
362
+ ),
363
+ policyLoaded ||
364
+ getEncryptionPolicy().then(
365
+ (result) => {
366
+ policyLoaded = true;
367
+ if (!cancelled) {
368
+ setEncryptionPolicy(result);
369
+ }
370
+ },
371
+ () => undefined,
372
+ ),
373
+ ]);
374
+ if (cancelled) {
375
+ return;
376
+ }
377
+ const failed = !mailboxLoaded || !policyLoaded;
378
+ setCryptoContextReady(true);
379
+ setCryptoLoadFailed(failed);
380
+ if (failed && retries < cryptoRetryDelaysMs.length) {
381
+ timer = setTimeout(() => void load(), cryptoRetryDelaysMs[retries++]);
208
382
  }
209
383
  }
210
- getMailbox(mailboxUid)
211
- .then((result) => {
212
- if (!cancelled) {
213
- setMailbox(result);
214
- }
215
- })
216
- .catch(() => undefined)
217
- .finally(() => {
218
- mailboxSettled = true;
219
- checkReady();
220
- });
221
- getEncryptionPolicy()
222
- .then((result) => {
223
- if (!cancelled) {
224
- setEncryptionPolicy(result);
225
- }
226
- })
227
- .catch(() => undefined)
228
- .finally(() => {
229
- policySettled = true;
230
- checkReady();
231
- });
384
+ void load();
232
385
  return () => {
233
386
  cancelled = true;
387
+ clearTimeout(timer);
234
388
  };
235
- }, [mailboxUid]);
389
+ }, [mailboxUid, cryptoRetryToken]);
236
390
 
237
391
  useEffect(() => {
238
392
  if (!mailboxUid) {
@@ -263,25 +417,74 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
263
417
  * without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
264
418
  * and not the same lookup `assembleForSend()` performs again at send time (deliberately: this is a
265
419
  * best-effort UI hint, not something a stale value here should be trusted to skip at send time).
266
- * A failed lookup or a mailbox/policy fetch that hasn't resolved yet just leaves that address with no
267
- * indicator rather than surfacing an error over what's a cosmetic nicety.
420
+ * A mailbox/policy fetch that hasn't resolved yet just leaves that address with no indicator - the effect
421
+ * below runs it again for every current recipient once both have loaded. A failed lookup leaves it without
422
+ * a status too, and is retried after `cryptoRetryDelaysMs` (then only on blur, Close or Retry).
423
+ *
424
+ * The statuses also drive autosave: a recipient without one yet (lookup pending, failed or never run) or a
425
+ * decision that would auto-encrypt keeps the plaintext draft save off (see `autosaveSuppressed`).
268
426
  */
269
427
  function checkRecipientDiscovery(addresses: ComposeRecipientInput[]) {
270
428
  if (!mailbox || !encryptionPolicy) {
271
429
  return;
272
430
  }
273
431
  const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
274
- const unchecked = addresses.filter((r) => !(r.address in recipientStatuses));
432
+ const generation = discoveryGenerationRef.current;
433
+ const inFlight = lookupsInFlightRef.current;
434
+ const unchecked = addresses.filter((r) => !(r.address in recipientStatuses) && !inFlight.has(r.address));
275
435
  for (const recipient of unchecked) {
276
- lookupKeys(mailbox.uid, recipient.address)
277
- .catch(() => undefined)
278
- .then((lookup) => {
279
- const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, lookup);
436
+ inFlight.add(recipient.address);
437
+ void lookupKeys(mailbox.uid, recipient.address)
438
+ .then(
439
+ (lookup) => ({ lookup }),
440
+ () => null,
441
+ )
442
+ .then((result) => {
443
+ inFlight.delete(recipient.address);
444
+ if (generation !== discoveryGenerationRef.current) {
445
+ return;
446
+ }
447
+ if (!result) {
448
+ noteLookupFailed(recipient.address);
449
+ return;
450
+ }
451
+ lookupFailuresRef.current.delete(recipient.address);
452
+ const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
280
453
  setRecipientStatuses((prev) => ({ ...prev, [recipient.address]: status }));
281
454
  });
282
455
  }
283
456
  }
284
457
 
458
+ /** Schedules the next automatic retry of a failed lookup, or marks the address exhausted once they've run out. */
459
+ function noteLookupFailed(address: string) {
460
+ const failures = (lookupFailuresRef.current.get(address) ?? 0) + 1;
461
+ lookupFailuresRef.current.set(address, failures);
462
+ if (failures > cryptoRetryDelaysMs.length) {
463
+ setExhaustedLookups((prev) => ({ ...prev, [address]: true }));
464
+ return;
465
+ }
466
+ const timer = setTimeout(() => {
467
+ lookupRetryTimersRef.current.delete(timer);
468
+ setLookupRetryToken((n) => n + 1);
469
+ }, cryptoRetryDelaysMs[failures - 1]);
470
+ lookupRetryTimersRef.current.add(timer);
471
+ }
472
+
473
+ useEffect(
474
+ () => () => {
475
+ for (const timer of lookupRetryTimersRef.current) {
476
+ clearTimeout(timer);
477
+ }
478
+ },
479
+ [],
480
+ );
481
+
482
+ // Prefilled recipients (a reply's To/Cc) are never blurred, and a blur before the mailbox/policy loaded
483
+ // was ignored - look every current recipient up as soon as both are available (and again on a lookup retry).
484
+ useEffect(() => {
485
+ checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
486
+ }, [mailbox, encryptionPolicy, lookupRetryToken]);
487
+
285
488
  // Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
286
489
  // plus any quoted original message, before `RichTextEditor` ever mounts (gated by `contentReady`
287
490
  // below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
@@ -302,9 +505,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
302
505
  signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward,
303
506
  );
304
507
  const signatureHtml = signature?.contentHtml ? `${signature.contentHtml}<p></p>` : "";
305
- setHtml(`${signatureHtml}${initialQuotedHtml ?? ""}`);
508
+ const seeded = `${signatureHtml}${initialQuotedHtml ?? ""}`;
509
+ setHtml(seeded);
510
+ setSeededHtml(seeded);
511
+ })
512
+ .catch(() => {
513
+ setHtml(initialQuotedHtml ?? "");
514
+ setSeededHtml(initialQuotedHtml ?? "");
306
515
  })
307
- .catch(() => setHtml(initialQuotedHtml ?? ""))
308
516
  .finally(() => setContentReady(true));
309
517
  }, [mailboxUid, signatureContext, initialQuotedHtml]);
310
518
 
@@ -321,6 +529,11 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
321
529
  return;
322
530
  }
323
531
  setDraft(created);
532
+ // Only now that the replacement exists is it safe to discard the draft(s) a From switch
533
+ // superseded - if creating this one had failed, the earlier draft is still there.
534
+ for (const superseded of supersededDraftsRef.current.splice(0)) {
535
+ void deleteSupersededDraft(superseded);
536
+ }
324
537
  })
325
538
  .catch((err) => {
326
539
  if (!cancelled) {
@@ -332,15 +545,72 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
332
545
  };
333
546
  }, [mailboxUid, draftsFolderUid, draft]);
334
547
 
335
- /** Switches the sending mailbox: discards the current (unsent, upload-free) draft and resets the
336
- * per-mailbox state, so the effects above start a fresh draft and crypto context in the new mailbox.
337
- * Recipients, subject, and body are kept. */
548
+ // Drafts a From switch superseded are otherwise only deleted once their replacement exists - closing the
549
+ // window (or sending, which closes it) first would orphan them.
550
+ useEffect(
551
+ () => () => {
552
+ for (const superseded of supersededDraftsRef.current.splice(0)) {
553
+ void deleteSupersededDraft(superseded);
554
+ }
555
+ },
556
+ [],
557
+ );
558
+
559
+ /**
560
+ * Deletes a draft a From switch replaced. An autosave of it may still be on the wire - it bumps the
561
+ * draft's version (see `saveDraftNow()`, which keeps `superseded.version` current while it's queued), so
562
+ * the delete waits for it and uses its version; a delete that still fails (e.g. another save of it landed
563
+ * meanwhile) is retried once with the version the server currently holds - unless it's no longer a draft
564
+ * (another window sent or scheduled it), which is left alone.
565
+ * Best-effort, never rejects.
566
+ */
567
+ async function deleteSupersededDraft(superseded: Message): Promise<void> {
568
+ const saved = await saveInFlightRef.current;
569
+ // Once it's out of `supersededDraftsRef` a landing save can't update `superseded.version` any more.
570
+ const version = saved?.uid === superseded.uid ? saved.version : superseded.version;
571
+ try {
572
+ await deleteMessage(superseded.uid, version);
573
+ } catch {
574
+ try {
575
+ const fresh = await getMessage(superseded.uid);
576
+ if (isStillDraft(fresh, superseded.folderUid)) {
577
+ await deleteMessage(fresh.uid, fresh.version);
578
+ }
579
+ } catch {
580
+ // Already gone, or the server is unreachable - nothing more to do from here.
581
+ }
582
+ }
583
+ }
584
+
585
+ // The From picker lists every mailbox straight away and drops view-only ones as each check answers - only
586
+ // mailboxes the caller can create a draft in are worth offering.
587
+ const writability = useMailboxWritability(mailboxes, userUid, trusted);
588
+ const fromOptions = mailboxes.filter((mb) => mb.uid === mailboxUid || writability[mb.uid] !== false);
589
+
590
+ // The sender turned out to be view-only (e.g. a reply to a message in a mailbox shared read-only, or a
591
+ // first-listed shared mailbox): switch to one the caller can actually send from, preferring their own.
592
+ const fromIsViewOnly = !!mailboxUid && writability[mailboxUid] === false;
593
+ useEffect(() => {
594
+ if (!fromIsViewOnly || hasUploads) {
595
+ return;
596
+ }
597
+ const ordered = [...mailboxes.filter((mb) => mb.ownerUserUid === userUid), ...mailboxes.filter((mb) => mb.ownerUserUid !== userUid)];
598
+ const alternative = ordered.find((mb) => writability[mb.uid] === true) ?? ordered.find((mb) => writability[mb.uid] === undefined);
599
+ if (alternative) {
600
+ handleFromChange(alternative.uid);
601
+ }
602
+ }, [fromIsViewOnly, writability]);
603
+
604
+ /** Switches the sending mailbox: supersedes the current (unsent, upload-free) draft and resets the
605
+ * per-mailbox state, so the effects above start a fresh draft and crypto context in the new mailbox -
606
+ * the old draft is deleted only once the new one has been created. Recipients, subject, and body are
607
+ * kept. */
338
608
  function handleFromChange(nextMailboxUid: string) {
339
609
  if (nextMailboxUid === mailboxUid || hasUploads) {
340
610
  return;
341
611
  }
342
612
  if (draft) {
343
- void deleteMessage(draft.uid, draft.version).catch(() => undefined);
613
+ supersededDraftsRef.current.push(draft);
344
614
  }
345
615
  setDraft(null);
346
616
  setDraftsFolderUid(undefined);
@@ -348,8 +618,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
348
618
  setFolderError(null);
349
619
  setMailbox(null);
350
620
  setRecipientStatuses({});
621
+ discoveryGenerationRef.current += 1;
622
+ lookupsInFlightRef.current = new Set();
623
+ lookupFailuresRef.current = new Map();
624
+ setExhaustedLookups({});
351
625
  setEncryptRequested(false);
352
626
  setEncryptionBlocked(null);
627
+ setSecurityBlock(null);
628
+ offeredCryptoRef.current = { sign: false, encrypt: false };
353
629
  setFromMailboxUid(nextMailboxUid);
354
630
  }
355
631
 
@@ -410,6 +686,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
410
686
  try {
411
687
  const attachment = await uploadAttachment(draft.uid, file);
412
688
  setHasUploads(true);
689
+ void refreshDraftVersion(draft.uid);
413
690
  return attachmentContentUrl(attachment.uid);
414
691
  } catch (err) {
415
692
  setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
@@ -417,6 +694,18 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
417
694
  }
418
695
  }
419
696
 
697
+ /** Uploading an attachment bumps the draft's version server-side (it records `hasAttachments`), so a later
698
+ * Discard would otherwise send a stale `?version=`. Best-effort: Discard also retries with the server's
699
+ * current version. */
700
+ async function refreshDraftVersion(uid: string) {
701
+ try {
702
+ const fresh = await getMessage(uid);
703
+ setDraft((prev) => (prev?.uid === fresh.uid && fresh.version > prev.version ? { ...prev, version: fresh.version } : prev));
704
+ } catch {
705
+ // Discard's own retry covers a version this couldn't refresh.
706
+ }
707
+ }
708
+
420
709
  // The "Attach files" input is itself `disabled` until `draft` resolves (see its `disabled={!draft}`
421
710
  // below), so this can only ever fire once `draft` is set — no defensive null check needed, matching
422
711
  // this codebase's established pattern for the same class of "always non-null by the time it's
@@ -431,6 +720,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
431
720
  const attachment = await uploadAttachment(draft!.uid, file);
432
721
  setHasUploads(true);
433
722
  setAttachments((prev) => [...prev, attachment]);
723
+ void refreshDraftVersion(draft!.uid);
434
724
  } catch (err) {
435
725
  setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
436
726
  }
@@ -440,19 +730,24 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
440
730
  /**
441
731
  * Builds this draft's final body — plaintext, signed-only, or signed+encrypted per
442
732
  * `specs/end-to-end_encryption.md` — then stores it via `assembleDraft()`/`assembleDraftRaw()`.
443
- * Shared by `handleSend()`/`handleScheduleSend()` (and the "Send without encryption" conflict button
444
- * rendered below, via `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
733
+ * Shared by immediate and scheduled sends (and the banners' override buttons rendered below, via
734
+ * `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
445
735
  *
446
- * Returns `"blocked"` (never throws for this case) when encryption was requested but not every
447
- * recipient can currently be encrypted to — the spec's "Multiple Recipients" all-or-nothing rule.
448
- * Sets `encryptionBlocked` as a side effect so the window can render that prompt; callers must stop
449
- * (not send) when they get this back.
736
+ * Returns `"blocked"` (never throws for this case) whenever the message can't go out the way the user
737
+ * asked for — never a silent downgrade to plaintext:
738
+ * encryption was requested (or auto-applies) but not every recipient can currently be encrypted to -
739
+ * the spec's "Multiple Recipients" all-or-nothing rule (sets `encryptionBlocked`); signing/encryption was
740
+ * on offer this session but the keys have since locked, or the mailbox/encryption policy needed to decide
741
+ * couldn't be loaded (sets `securityBlock`, and re-prompts the unlock dialog when the keys are merely
742
+ * locked); or the message would be encrypted but has Bcc recipients - every recipient's certificate goes
743
+ * into one shared envelope, which would disclose the Bcc list to every other recipient, and the send API
744
+ * has no per-recipient-copy submission to avoid that (sets `securityBlock`).
745
+ * Callers must stop (not send) when they get this back. `forcePlaintext` (the user's explicit choice
746
+ * from one of those banners) skips every one of these checks and never encrypts; signing still
747
+ * happens if it's currently possible.
450
748
  *
451
749
  * Discovery (`lookupKeys()`) and the resulting sign/encrypt decision only run at this, the final
452
- * pre-send step — not live as recipients are typed. A compose-time recipient-entry indicator (key
453
- * icons, live discovery as each address is entered) is Phase 4's "Discovery & contacts UI" work, not
454
- * this pass; this still satisfies the spec's "MUST be called lazily at compose time, not on receipt"
455
- * requirement, just without a live UI reflecting it before Send is clicked.
750
+ * pre-send step — `checkRecipientDiscovery()`'s on-blur badges are a best-effort hint only.
456
751
  */
457
752
  async function assembleForSend(
458
753
  toRecipients: ComposeRecipientInput[],
@@ -460,39 +755,93 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
460
755
  bccRecipients: ComposeRecipientInput[],
461
756
  forcePlaintext: boolean,
462
757
  ): Promise<Message | "blocked"> {
758
+ // Keys are read at the moment they're needed, and again after any await: they can lock (idle
759
+ // timeout, sign-out elsewhere) mid-send, and a destroyed `UnlockedKeys` object must count as locked.
463
760
  // Only reachable once a draft exists, which itself requires `mailboxUid` to have resolved.
464
- const unlocked = getUnlockedKeys(mailboxUid!);
465
- // `mailbox` is required to build protected headers (own From address) whenever signing or
466
- // encrypting - not just guarded on the encryption branch below, since a signed-only message
467
- // needs it too. A mailbox fetch failure just means no crypto for this send, never a crash.
468
- const canSign = signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer;
469
- const canEncryptSelf = !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer;
761
+ function readKeys() {
762
+ const stored = getUnlockedKeys(mailboxUid!);
763
+ const unlocked = stored?.destroyed ? undefined : stored;
764
+ return {
765
+ unlocked,
766
+ // `mailbox` is required to build protected headers (own From address) whenever signing or
767
+ // encrypting - not just guarded on the encryption branch below, since a signed-only message
768
+ // needs it too.
769
+ canSign: signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer,
770
+ canEncryptSelf: !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer,
771
+ };
772
+ }
773
+ let keys = readKeys();
470
774
  const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
471
775
 
776
+ // The Sign toggle was shown (and is still checked), but signing can't actually happen any more.
777
+ const signingLost = () => !forcePlaintext && signEnabled && offeredCryptoRef.current.sign && !keys.canSign;
778
+ if (signingLost()) {
779
+ return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
780
+ }
781
+
782
+ if (!forcePlaintext && !mailbox) {
783
+ // The mailbox's enrolled keys are what say whether this message could be encrypted at all - with keys
784
+ // that aren't unlocked in this session, a failed mailbox load would otherwise skip encryption silently.
785
+ return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
786
+ }
787
+
472
788
  let wantEncrypt = false;
473
789
  let recipientCertDers: Uint8Array[] = [];
474
- if (!forcePlaintext && canEncryptSelf && mailbox && encryptionPolicy && allRecipients.length > 0) {
475
- const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
476
- const lookups = await Promise.all(allRecipients.map((r) => lookupKeys(mailboxUid!, r.address).catch(() => undefined)));
790
+ // Entered whenever this message could be encrypted at all - including a mailbox with an enrolled
791
+ // encryption key that this session never unlocked: policy may still auto-encrypt, and that must block
792
+ // (and prompt to unlock) rather than quietly send plaintext.
793
+ if (!forcePlaintext && (keys.canEncryptSelf || offeredCryptoRef.current.encrypt || hasEnrolledEncryptionKey)) {
794
+ if (!encryptionPolicy) {
795
+ // Without it, there's no telling whether policy auto-encrypts this message.
796
+ return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
797
+ }
798
+ // Reachable only with `mailbox` loaded (checked above).
799
+ const ownPrefersMutual = mailbox!.encryptPreference?.preferEncrypt === "mutual";
800
+ const lookups = await Promise.all(
801
+ allRecipients.map((r) =>
802
+ lookupKeys(mailboxUid!, r.address).then(
803
+ (lookup) => ({ lookup }),
804
+ () => null,
805
+ ),
806
+ ),
807
+ );
808
+ keys = readKeys();
809
+ if (signingLost()) {
810
+ return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
811
+ }
812
+ // A failed lookup isn't "no key": treating it as one could send plaintext that policy would encrypt.
813
+ if (lookups.some((result) => !result) && (encryptRequested || policyCanAutoEncrypt(encryptionPolicy))) {
814
+ return blockSend(LOOKUP_UNAVAILABLE_MESSAGE, "Send without encryption", false);
815
+ }
477
816
  const statuses = allRecipients.map((r, i) =>
478
- resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]),
817
+ resolveRecipientEncryption(mailbox!.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]?.lookup),
479
818
  );
480
819
  const decision = decideMessageEncryption(statuses);
481
820
  wantEncrypt = encryptRequested || decision.autoEncrypt;
482
821
  if (wantEncrypt) {
822
+ if (!keys.canEncryptSelf) {
823
+ return blockSend(KEYS_LOCKED_ENCRYPT_MESSAGE, "Send without encryption", !keys.unlocked);
824
+ }
483
825
  if (!decision.canEncryptAll) {
484
826
  setEncryptionBlocked(decision.blockedRecipients);
485
827
  return "blocked";
486
828
  }
487
- recipientCertDers = [unlocked!.encryptionCertDer!, ...statuses.map((s) => fromBase64(s.encryptCert!.publicKey))];
829
+ if (bccRecipients.length > 0) {
830
+ return blockSend(BCC_ENCRYPTED_MESSAGE, "Send without encryption", false);
831
+ }
832
+ recipientCertDers = [keys.unlocked!.encryptionCertDer!, ...statuses.map((s) => fromBase64(s.encryptCert!.publicKey))];
488
833
  }
489
834
  }
490
835
 
836
+ const { canSign } = keys;
491
837
  if ((wantEncrypt || canSign) && attachments.length > 0) {
492
838
  // Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
493
839
  // clearer explanation than that route's generic 400 rather than via a round trip.
494
840
  throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
495
841
  }
842
+ if ((wantEncrypt || canSign) && INLINE_IMAGE_PATTERN.test(html)) {
843
+ throw new ApiRequestError("A signed or encrypted message cannot include inline images yet - remove them before sending.", 400);
844
+ }
496
845
 
497
846
  if (!wantEncrypt && !canSign) {
498
847
  // See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
@@ -502,9 +851,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
502
851
  }
503
852
 
504
853
  const domain = mailbox!.primarySmtpAddress.split("@")[1] ?? "localhost";
854
+ const displayName = mailbox!.displayName && !UNSAFE_DISPLAY_NAME_PATTERN.test(mailbox!.displayName) ? mailbox!.displayName : "";
505
855
  const protectedHeaders: ProtectedHeaders = {
506
- from: mailbox!.displayName
507
- ? `"${mailbox!.displayName.replace(/"/g, '\\"')}" <${mailbox!.primarySmtpAddress}>`
856
+ from: displayName
857
+ ? `"${displayName.replace(/"/g, '\\"')}" <${mailbox!.primarySmtpAddress}>`
508
858
  : mailbox!.primarySmtpAddress,
509
859
  to: toRecipients.map((r) => r.address).join(", "),
510
860
  cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
@@ -513,7 +863,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
513
863
  messageId: `<${crypto.randomUUID()}@${domain}>`,
514
864
  };
515
865
  const bodyContentType = 'text/html; charset="utf-8"';
516
- const signing = canSign ? { certDer: unlocked!.signingCertDer!, privateKey: unlocked!.signingPrivateKey! } : undefined;
866
+ const signing = canSign ? { certDer: keys.unlocked!.signingCertDer!, privateKey: keys.unlocked!.signingPrivateKey! } : undefined;
517
867
 
518
868
  const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
519
869
  const mimePart = wantEncrypt
@@ -523,9 +873,26 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
523
873
  return assembleDraftRaw(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
524
874
  }
525
875
 
526
- // Same reasoning as `handleFilesSelected` above: the Send button is itself `disabled` until `draft`
527
- // resolves, so this is never reachable with a null `draft`.
528
- async function handleSend(forcePlaintext = false) {
876
+ /** Records why a send was refused (see `assembleForSend()`), re-prompting the unlock dialog when the
877
+ * cause is keys that are enrolled but locked. */
878
+ function blockSend(message: string, overrideLabel: string, keysLocked: boolean): "blocked" {
879
+ setSecurityBlock({ message, overrideLabel });
880
+ if (keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
881
+ void handleUnlockForCrypto();
882
+ }
883
+ return "blocked";
884
+ }
885
+
886
+ /**
887
+ * Sends (or, given `scheduledSendTimeIso`, schedules) the draft. Same reasoning as `handleFilesSelected`
888
+ * above: Send and the "Send later" caret are both `disabled` until `draft` resolves, so this is never
889
+ * reachable with a null `draft`. A scheduled send assembles the draft first (same as an immediate send),
890
+ * then sets `scheduledSendTime` on the *freshly assembled* copy before calling `sendMessage()` — `send()`
891
+ * itself is what reads the field and defers relay into Outbox instead of sending immediately (see
892
+ * `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`). A blocked send remembers its schedule
893
+ * time so the banners' override buttons replay it as the same kind of send.
894
+ */
895
+ async function submit(forcePlaintext: boolean, scheduledSendTimeIso?: string) {
529
896
  const toRecipients = parseAddresses(to);
530
897
  if (toRecipients.length === 0) {
531
898
  setSendError("At least one recipient is required.");
@@ -533,63 +900,247 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
533
900
  }
534
901
 
535
902
  setSending(true);
903
+ sendingRef.current = true;
536
904
  setSendError(null);
905
+ setSecurityBlock(null);
906
+ setEncryptionBlocked(null);
907
+ pendingSaveRef.current = false;
537
908
  try {
909
+ // An autosave already on the wire must never land after (and overwrite) the send's own assembly.
910
+ await saveInFlightRef.current;
538
911
  const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), forcePlaintext);
539
912
  if (assembled === "blocked") {
913
+ setBlockedScheduleIso(scheduledSendTimeIso);
540
914
  return;
541
915
  }
916
+ // The draft now holds exactly this content (possibly signed/encrypted) - don't autosave a
917
+ // plaintext copy over it if the rest of the send fails.
918
+ lastSavedRef.current = `${assembled.uid}:${contentKey}`;
542
919
  const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
543
- await sendMessage(withReceipt.uid);
920
+ await sendMessage(withReceipt.uid, scheduledSendTimeIso ? { scheduledSendTime: scheduledSendTimeIso } : undefined);
921
+ finishedRef.current = true;
544
922
  onClose();
545
923
  } catch (err) {
546
- setSendError(err instanceof ApiRequestError ? err.message : "Could not send this message.");
924
+ const fallback = scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.";
925
+ setSendError(err instanceof ApiRequestError ? err.message : fallback);
547
926
  } finally {
927
+ sendingRef.current = false;
548
928
  setSending(false);
549
929
  }
550
930
  }
551
931
 
552
- // Same reasoning as `handleSend` above (and the "Send later" caret is itself `disabled` alongside
553
- // Send until `draft` resolves) — never reachable with a null `draft`. Assembles the draft first
554
- // (same as an immediate send), then sets `scheduledSendTime` on the *freshly assembled* copy before
555
- // calling `sendMessage()` — `send()` itself is what reads the field and defers relay into Outbox
556
- // instead of sending immediately (see `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`).
557
- async function handleScheduleSend(scheduledSendTimeIso: string) {
558
- const toRecipients = parseAddresses(to);
932
+ function handleScheduleSend(scheduledSendTimeIso: string) {
559
933
  setSchedulePickerOpen(false);
560
- if (toRecipients.length === 0) {
561
- setSendError("At least one recipient is required.");
562
- return;
934
+ void submit(false, scheduledSendTimeIso);
935
+ }
936
+
937
+ async function handleUnlockForCrypto() {
938
+ try {
939
+ // Only reachable when hasEnrolledSigningKey/hasEnrolledEncryptionKey found a real enrolled key
940
+ // in mailbox.keys (the unlock button below, or blockSend()), so neither is empty/undefined here.
941
+ await requestUnlock(mailbox!.uid, mailbox!.keys!);
942
+ setUnlockRefresh((n) => n + 1);
943
+ } catch {
944
+ // User dismissed the unlock dialog - nothing to do, the toggles below simply stay hidden.
563
945
  }
946
+ }
564
947
 
565
- setSending(true);
566
- setSendError(null);
948
+ /**
949
+ * Saves the window's current recipients/subject/body onto its server draft via `assembleDraft()` (the
950
+ * same plaintext assembly a normal send uses). Chained after any save already on the wire - two
951
+ * overlapping assemblies of the same draft race on its version and the later one is rejected - and reads
952
+ * `latestRef` only once it actually runs, so it saves what was last typed (skipping the request when the
953
+ * save before it already stored exactly that). Whether a save may happen at all is re-checked at that same
954
+ * moment: while it waited, the message may have become one that must not be stored as plaintext (Encrypt
955
+ * turned on, a recipient that auto-encrypts), or the window may have started sending or finished - then it
956
+ * resolves `{ skipped: true }` without a request (`message` then being the previous save's result). Never
957
+ * rejects; `message` is `undefined` when the save failed.
958
+ */
959
+ function saveDraftNow(): Promise<{ message?: Message; skipped?: boolean }> {
960
+ const target = latestRef.current.draft!;
961
+ const previous = saveInFlightRef.current;
962
+ pendingSaveRef.current = false;
963
+ setSaveStatus("saving");
964
+ const outcome = (async (): Promise<{ message?: Message; skipped?: boolean }> => {
965
+ const before = await previous;
966
+ const current = latestRef.current;
967
+ if (current.autosaveSuppressed || sendingRef.current || finishedRef.current) {
968
+ setSaveStatus("idle");
969
+ // Still the newest copy the server holds, for the version a later delete needs.
970
+ return { skipped: true, message: before };
971
+ }
972
+ const savedKey = `${target.uid}:${current.contentKey}`;
973
+ if (before?.uid === target.uid && lastSavedRef.current === savedKey) {
974
+ setSaveStatus("saved");
975
+ return { message: before };
976
+ }
977
+ try {
978
+ const saved = await assembleDraft(target.uid, {
979
+ to: parseAddresses(current.to),
980
+ cc: parseAddresses(current.cc),
981
+ bcc: parseAddresses(current.bcc),
982
+ subject: current.subject,
983
+ html: current.html,
984
+ });
985
+ lastSavedRef.current = savedKey;
986
+ saveErrorRef.current = null;
987
+ // A From switch may have replaced the draft while this was in flight - never resurrect it,
988
+ // but do keep its superseded copy's version current so its pending delete still matches.
989
+ setDraft((prev) => (prev?.uid === saved.uid ? saved : prev));
990
+ const superseded = supersededDraftsRef.current.find((d) => d.uid === saved.uid);
991
+ if (superseded) {
992
+ superseded.version = saved.version;
993
+ }
994
+ setSaveStatus("saved");
995
+ return { message: saved };
996
+ } catch (err) {
997
+ saveErrorRef.current = err instanceof ApiRequestError ? err.message : "the server couldn't be reached";
998
+ setSaveStatus("error");
999
+ return {};
1000
+ }
1001
+ })();
1002
+ saveInFlightRef.current = outcome.then((result) => result.message);
1003
+ return outcome;
1004
+ }
1005
+
1006
+ /** Keeps the window open after its content couldn't be saved, offering Discard or Keep editing. */
1007
+ function showSaveFailedPrompt() {
1008
+ setClosePrompt({
1009
+ title: "Couldn't save this draft",
1010
+ message: `It couldn't be saved (${saveErrorRef.current!.replace(/\.$/, "")}). Keep editing and try again, or discard it.`,
1011
+ });
1012
+ }
1013
+
1014
+ /**
1015
+ * Deletes `current` (this window's server draft) once any save already on the wire has landed, so the delete
1016
+ * carries the draft's current version. A delete rejected for a stale version (404/409: e.g. an attachment
1017
+ * upload bumped it) is retried once with the version the server holds - and a draft the server no longer has
1018
+ * counts as deleted. The retry only happens while the server copy is still an unsent draft in the same folder.
1019
+ * Rejects when the draft couldn't be deleted.
1020
+ */
1021
+ async function deleteDraft(current: Message): Promise<void> {
1022
+ const saved = await saveInFlightRef.current;
1023
+ const version = Math.max(current.version, saved?.uid === current.uid ? saved.version : 0);
567
1024
  try {
568
- const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), false);
569
- if (assembled === "blocked") {
1025
+ await deleteMessage(current.uid, version);
1026
+ } catch (err) {
1027
+ if (!(err instanceof ApiRequestError) || (err.status !== 404 && err.status !== 409)) {
1028
+ throw err;
1029
+ }
1030
+ const fresh = await getMessage(current.uid).catch((getErr: unknown) => {
1031
+ if (getErr instanceof ApiRequestError && getErr.status === 404) {
1032
+ return null;
1033
+ }
1034
+ throw getErr;
1035
+ });
1036
+ if (!fresh) {
570
1037
  return;
571
1038
  }
572
- const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
573
- const scheduled = await setMessageScheduledSendTime(withReceipt, scheduledSendTimeIso);
574
- await sendMessage(scheduled.uid);
1039
+ // The version changed because another window sent or scheduled it - that message isn't ours to delete.
1040
+ if (!isStillDraft(fresh, current.folderUid)) {
1041
+ throw new ApiRequestError(NO_LONGER_A_DRAFT_MESSAGE, 409);
1042
+ }
1043
+ await deleteMessage(fresh.uid, fresh.version);
1044
+ }
1045
+ }
1046
+
1047
+ /**
1048
+ * Discards the window's draft and closes it. With nothing the user would lose (a blank draft) it closes
1049
+ * straight away and deletes the draft in the background; otherwise it closes only once the draft is
1050
+ * deleted, and a delete that fails keeps the window open with the error.
1051
+ */
1052
+ async function discardNow() {
1053
+ finishedRef.current = true;
1054
+ pendingSaveRef.current = false;
1055
+ setClosePrompt(null);
1056
+ setDiscardError(null);
1057
+ const current = draft;
1058
+ if (!current || !hasUserContent) {
1059
+ if (current) {
1060
+ void deleteDraft(current).catch(() => undefined);
1061
+ }
575
1062
  onClose();
1063
+ return;
1064
+ }
1065
+ setClosing(true);
1066
+ try {
1067
+ await deleteDraft(current);
576
1068
  } catch (err) {
577
- setSendError(err instanceof ApiRequestError ? err.message : "Could not schedule this message.");
578
- } finally {
579
- setSending(false);
1069
+ finishedRef.current = false;
1070
+ setClosing(false);
1071
+ setDiscardError(`Couldn't discard this draft: ${err instanceof ApiRequestError ? err.message : "the server couldn't be reached."}`);
1072
+ return;
580
1073
  }
1074
+ onClose();
581
1075
  }
582
1076
 
583
- async function handleUnlockForCrypto() {
584
- try {
585
- // Only reachable via the button below, which never renders unless needsUnlockForCrypto is
586
- // true - which itself requires mailbox.keys to already contain a real enrolled key (see
587
- // hasEnrolledSigningKey/hasEnrolledEncryptionKey), so it's never empty/undefined here either.
588
- await requestUnlock(mailbox!.uid, mailbox!.keys!);
589
- setUnlockRefresh((n) => n + 1);
590
- } catch {
591
- // User dismissed the unlock dialog - nothing to do, the toggles below simply stay hidden.
1077
+ /** "Discard draft": confirms first whenever there's anything the user would lose. */
1078
+ function handleDiscard() {
1079
+ if (hasUserContent) {
1080
+ setClosePrompt({ title: DISCARD_TITLE, message: "This permanently deletes this draft and everything you've written in it." });
1081
+ } else {
1082
+ void discardNow();
1083
+ }
1084
+ }
1085
+
1086
+ /** "Close": keeps the draft (saving any unsaved edits first, and closing only once that save succeeded) -
1087
+ * unless there's nothing in it worth keeping, or it can't be saved because it's headed for encryption, or
1088
+ * whether it is can't be told yet (settings loading or unavailable, recipient lookups pending): those need
1089
+ * the same confirmation as a discard. A failed save keeps the window open with Discard / Keep editing.
1090
+ * Close and Discard are both disabled while a send is in progress: deleting the draft, or saving a
1091
+ * plaintext copy over the message the send just assembled, mid-send would lose or leak it. */
1092
+ async function handleClose() {
1093
+ if (!hasUserContent) {
1094
+ void discardNow();
1095
+ return;
1096
+ }
1097
+ if (encryptionDecided) {
1098
+ setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
1099
+ return;
1100
+ }
1101
+ if (cryptoCheckUnavailable) {
1102
+ setClosePrompt({ title: DISCARD_TITLE, message: CRYPTO_UNAVAILABLE_MESSAGE, retry: true });
1103
+ return;
1104
+ }
1105
+ if (awaitingRecipients) {
1106
+ setClosePrompt({ title: DISCARD_TITLE, message: NO_RECIPIENTS_CLOSE_MESSAGE });
1107
+ return;
1108
+ }
1109
+ if (encryptionUndetermined) {
1110
+ // A recipient typed but never blurred has no lookup running yet.
1111
+ checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
1112
+ setClosePrompt({ title: DISCARD_TITLE, message: CHECKING_CLOSE_MESSAGE });
1113
+ return;
1114
+ }
1115
+ if (!draft || !contentReady) {
1116
+ saveErrorRef.current = draftError ?? "the draft hasn't been created yet";
1117
+ showSaveFailedPrompt();
1118
+ return;
1119
+ }
1120
+ if (lastSavedRef.current !== `${draft.uid}:${contentKey}`) {
1121
+ setClosing(true);
1122
+ const outcome = await saveDraftNow();
1123
+ setClosing(false);
1124
+ if (outcome.skipped) {
1125
+ // It became a message that mustn't be saved as plaintext while waiting on an earlier save.
1126
+ setClosePrompt({ title: DISCARD_TITLE, message: latestRef.current.encryptionDecided ? ENCRYPTED_CLOSE_MESSAGE : CHECKING_CLOSE_MESSAGE });
1127
+ return;
1128
+ }
1129
+ if (!outcome.message) {
1130
+ showSaveFailedPrompt();
1131
+ return;
1132
+ }
592
1133
  }
1134
+ finishedRef.current = true;
1135
+ onClose();
1136
+ }
1137
+
1138
+ function retryCryptoContext() {
1139
+ setClosePrompt(null);
1140
+ setCryptoRetryToken((n) => n + 1);
1141
+ lookupFailuresRef.current = new Map();
1142
+ setExhaustedLookups({});
1143
+ setLookupRetryToken((n) => n + 1);
593
1144
  }
594
1145
 
595
1146
  // A plain read from keySession.ts's module-level session store, not React state - see that module's
@@ -601,15 +1152,163 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
601
1152
  // the sign/encrypt toggles below stay hidden until it has, so this is the only way to reach them
602
1153
  // without first stumbling into Mail generally (which no longer force-prompts on its own - see
603
1154
  // `MailShell`'s `blocking={false}`). `getUnlockedKeys()` is re-read as a plain module value above, so
604
- // bumping `unlockRefresh` after a successful unlock is what makes this line (and the toggles it
605
- // gates) reflect it on the next render.
1155
+ // bumping `unlockRefresh` (after a successful unlock, or on any key-session event for this mailbox -
1156
+ // see the `subscribeKeySession()` effect) is what makes this line (and the toggles it gates) reflect
1157
+ // it on the next render.
606
1158
  const needsUnlockForCrypto = (hasEnrolledSigningKey || hasEnrolledEncryptionKey) && !unlockedKeys;
607
1159
 
1160
+ // Keys locking (idle timeout, logout elsewhere, ...) or unlocking from another window must update the
1161
+ // toggles/unlock affordance right away, not on whatever unrelated render happens next.
1162
+ useEffect(() => subscribeKeySession((event) => {
1163
+ if (event.mailboxUid === mailboxUid) {
1164
+ setUnlockRefresh((n) => n + 1);
1165
+ }
1166
+ }), [mailboxUid]);
1167
+
1168
+ useEffect(() => {
1169
+ offeredCryptoRef.current = {
1170
+ sign: offeredCryptoRef.current.sign || !!unlockedKeys?.signingPrivateKey,
1171
+ encrypt: offeredCryptoRef.current.encrypt || !!unlockedKeys?.encryptionPrivateKey,
1172
+ };
1173
+ });
1174
+
1175
+ // Autosave. "User content" is anything beyond what compose opened with (prefilled recipients/subject,
1176
+ // seeded signature/quote) - an untouched window has nothing worth saving or confirming a discard of.
1177
+ const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
1178
+ const baselineKey = JSON.stringify([initialTo ?? "", initialCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
1179
+ const hasUserContent = contentKey !== baselineKey || hasUploads;
1180
+ // A message headed for encryption is never autosaved: the draft would store its plaintext server-side,
1181
+ // defeating end-to-end encryption. Until it's known not to be, it isn't either: while the mailbox (whose
1182
+ // enrolled keys say whether encryption is possible at all) or the encryption policy hasn't loaded, and -
1183
+ // whenever this message could be encrypted (an unlocked or enrolled encryption key) - while the policy is
1184
+ // missing or any current recipient's lookup is still pending (or failed, or hasn't run) - and while there are no
1185
+ // recipients at all when policy auto-encrypts some tier, since the first one added may turn encryption on.
1186
+ const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
1187
+ const currentStatuses = currentAddresses.map((address) => recipientStatuses[address]);
1188
+ const encryptionPossible = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
1189
+ /** Could be auto-encrypted, but has no recipients yet to decide by. */
1190
+ const awaitingRecipients = encryptionPossible && !!encryptionPolicy && currentAddresses.length === 0 && policyCanAutoEncrypt(encryptionPolicy);
1191
+ const encryptionUndetermined =
1192
+ !cryptoContextReady || !mailbox || awaitingRecipients || (encryptionPossible && (!encryptionPolicy || currentStatuses.some((s) => !s)));
1193
+ /** Known to be headed for encryption: requested, or policy auto-encrypts it. */
1194
+ const encryptionDecided = encryptRequested || (!encryptionUndetermined && encryptionPossible && decideMessageEncryption(currentStatuses).autoEncrypt);
1195
+ const autosaveSuppressed = encryptionDecided || encryptionUndetermined;
1196
+ /** Loading the mailbox/policy failed (retries may still be pending) and this message's encryption hinges on it, or
1197
+ * a current recipient's key lookup kept failing. */
1198
+ const cryptoCheckUnavailable =
1199
+ (cryptoContextReady && cryptoLoadFailed && (!mailbox || (encryptionPossible && !encryptionPolicy))) ||
1200
+ (encryptionPossible && currentAddresses.some((address) => exhaustedLookups[address] && !recipientStatuses[address]));
1201
+ const latestRef = useRef({ draft, to, cc, bcc, subject, html, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided });
1202
+ latestRef.current = { draft, to, cc, bcc, subject, html, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided };
1203
+
1204
+ useEffect(() => {
1205
+ const due =
1206
+ !!draft &&
1207
+ contentReady &&
1208
+ cryptoContextReady &&
1209
+ !sending &&
1210
+ !autosaveSuppressed &&
1211
+ hasUserContent &&
1212
+ lastSavedRef.current !== `${draft.uid}:${contentKey}`;
1213
+ pendingSaveRef.current = due;
1214
+ if (!due) {
1215
+ return;
1216
+ }
1217
+ const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
1218
+ return () => clearTimeout(timer);
1219
+ }, [draft, contentReady, cryptoContextReady, sending, autosaveSuppressed, hasUserContent, contentKey]);
1220
+
1221
+ // Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
1222
+ // right away rather than losing it.
1223
+ useEffect(
1224
+ () => () => {
1225
+ if (pendingSaveRef.current && !finishedRef.current) {
1226
+ void saveDraftNow();
1227
+ }
1228
+ },
1229
+ [],
1230
+ );
1231
+
1232
+ /** Saves an edit still waiting on the debounce now, and resolves once that save - or one already on the
1233
+ * wire - has settled, to whether the window's content is saved. A save that failed keeps the window open
1234
+ * with Discard / Keep editing. A send clears `pendingSaveRef` before it starts, so this never saves mid-send. */
1235
+ async function flushPendingSave(): Promise<boolean> {
1236
+ if (pendingSaveRef.current && !finishedRef.current) {
1237
+ const outcome = await saveDraftNow();
1238
+ if (outcome.skipped) {
1239
+ // Not saved, but not a failure either: it may be headed for encryption (or was sent/discarded meanwhile).
1240
+ return finishedRef.current;
1241
+ }
1242
+ if (!outcome.message) {
1243
+ showSaveFailedPrompt();
1244
+ }
1245
+ return !!outcome.message;
1246
+ }
1247
+ await saveInFlightRef.current;
1248
+ const latest = latestRef.current;
1249
+ if (finishedRef.current || !latest.hasUserContent || (!!latest.draft && lastSavedRef.current === `${latest.draft.uid}:${latest.contentKey}`)) {
1250
+ return true;
1251
+ }
1252
+ // Unsaved because the last save failed (rather than, say, an encrypted message never being saved).
1253
+ if (saveErrorRef.current) {
1254
+ showSaveFailedPrompt();
1255
+ }
1256
+ return false;
1257
+ }
1258
+
1259
+ // Sign Out (AppShell) waits for this before ending the session.
1260
+ useEffect(() => registerComposeFlush(flushPendingSave), []);
1261
+
1262
+ // Leaving the page (reload, closing the tab, following a link) with anything not saved: start a save that's
1263
+ // waiting on the debounce straight away and ask the browser to confirm, so the last couple of seconds of
1264
+ // typing (or content that can't be saved as a draft, or failed to) aren't lost silently. Never while signing
1265
+ // out - that navigation must not be cancellable.
1266
+ useEffect(() => {
1267
+ function handleBeforeUnload(event: BeforeUnloadEvent) {
1268
+ if (finishedRef.current || sendingRef.current || isSigningOut()) {
1269
+ return;
1270
+ }
1271
+ const latest = latestRef.current;
1272
+ const unsaved =
1273
+ pendingSaveRef.current ||
1274
+ latest.saveStatus === "saving" ||
1275
+ (latest.hasUserContent && lastSavedRef.current !== `${latest.draft?.uid}:${latest.contentKey}`);
1276
+ if (!unsaved) {
1277
+ return;
1278
+ }
1279
+ void flushPendingSave();
1280
+ event.preventDefault();
1281
+ event.returnValue = "";
1282
+ }
1283
+ window.addEventListener("beforeunload", handleBeforeUnload);
1284
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
1285
+ }, []);
1286
+
608
1287
  const title = subject.trim() || "New Message";
609
1288
  const titleId = `compose-title-${id}`;
610
1289
 
1290
+ const discardModal = (
1291
+ <Modal open={closePrompt !== null} onClose={() => setClosePrompt(null)} title={closePrompt?.title ?? DISCARD_TITLE}>
1292
+ <p className="text-sm text-text-muted mb-4">{closePrompt?.message}</p>
1293
+ <div className="flex gap-3">
1294
+ {closePrompt?.retry && (
1295
+ <Button type="button" onClick={retryCryptoContext} className="!w-auto">
1296
+ Retry
1297
+ </Button>
1298
+ )}
1299
+ <Button type="button" onClick={() => void discardNow()} disabled={sending || closing} className="!w-auto">
1300
+ Discard
1301
+ </Button>
1302
+ <Button type="button" variant="secondary" onClick={() => setClosePrompt(null)} className="!w-auto">
1303
+ Keep editing
1304
+ </Button>
1305
+ </div>
1306
+ </Modal>
1307
+ );
1308
+
611
1309
  if (minimized) {
612
1310
  return (
1311
+ <>
613
1312
  <div role="dialog" aria-label={title} className="w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal">
614
1313
  <div
615
1314
  className="h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer"
@@ -618,18 +1317,17 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
618
1317
  <span className="text-sm font-medium truncate">{title}</span>
619
1318
  <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
620
1319
  <HeaderButton label="Restore" onClick={onToggleMinimize} icon={HiOutlineArrowsPointingOut} />
621
- <HeaderButton
622
- label="Discard draft"
623
- onClick={onClose}
624
- icon={HiOutlineXMark}
625
- />
1320
+ <HeaderButton label="Discard draft" onClick={handleDiscard} icon={HiOutlineXMark} disabled={sending || closing} />
626
1321
  </div>
627
1322
  </div>
628
1323
  </div>
1324
+ {discardModal}
1325
+ </>
629
1326
  );
630
1327
  }
631
1328
 
632
1329
  return (
1330
+ <>
633
1331
  <div
634
1332
  ref={windowRef}
635
1333
  role="dialog"
@@ -679,17 +1377,29 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
679
1377
  icon={expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut}
680
1378
  />
681
1379
  )}
682
- <HeaderButton label="Close" onClick={onClose} icon={HiOutlineXMark} />
1380
+ <HeaderButton label="Close" onClick={() => void handleClose()} icon={HiOutlineXMark} disabled={sending || closing} />
683
1381
  </div>
684
1382
  </div>
685
1383
 
686
1384
  <div className="flex-1 min-h-0 flex flex-col">
687
- {(folderError || draftError || sendError || attachError) && (
1385
+ {(folderError || draftError || sendError || attachError || discardError) && (
688
1386
  <div className="px-3 pt-2">
689
1387
  {folderError && <Alert>{folderError}</Alert>}
690
1388
  {draftError && <Alert>{draftError}</Alert>}
691
1389
  {sendError && <Alert>{sendError}</Alert>}
692
1390
  {attachError && <Alert>{attachError}</Alert>}
1391
+ {discardError && <Alert>{discardError}</Alert>}
1392
+ </div>
1393
+ )}
1394
+
1395
+ {cryptoCheckUnavailable && (
1396
+ <div className="px-3 pt-2">
1397
+ <Alert>
1398
+ <p className="mb-2">{CRYPTO_UNAVAILABLE_MESSAGE}</p>
1399
+ <Button type="button" variant="secondary" className="!w-auto" onClick={retryCryptoContext}>
1400
+ Retry
1401
+ </Button>
1402
+ </Alert>
693
1403
  </div>
694
1404
  )}
695
1405
 
@@ -702,20 +1412,25 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
702
1412
  Remove {encryptionBlocked.length > 1 ? "these recipients" : "this recipient"} from To/Cc/Bcc, or send the
703
1413
  whole message in plaintext.
704
1414
  </p>
705
- <Button
706
- type="button"
707
- onClick={() => {
708
- setEncryptionBlocked(null);
709
- void handleSend(true);
710
- }}
711
- >
1415
+ <Button type="button" disabled={sending} onClick={() => void submit(true, blockedScheduleIso)}>
712
1416
  Send without encryption
713
1417
  </Button>
714
1418
  </Alert>
715
1419
  </div>
716
1420
  )}
717
1421
 
718
- {mailboxes.length > 1 && (
1422
+ {securityBlock && (
1423
+ <div className="px-3 pt-2">
1424
+ <Alert>
1425
+ <p className="mb-2">{securityBlock.message}</p>
1426
+ <Button type="button" disabled={sending} onClick={() => void submit(true, blockedScheduleIso)}>
1427
+ {securityBlock.overrideLabel}
1428
+ </Button>
1429
+ </Alert>
1430
+ </div>
1431
+ )}
1432
+
1433
+ {fromOptions.length > 1 && (
719
1434
  <div className={FIELD_ROW}>
720
1435
  <label htmlFor={`compose-from-${id}`} className="text-xs text-text-muted shrink-0">
721
1436
  From
@@ -728,7 +1443,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
728
1443
  title={hasUploads ? "The sender can't be changed after adding attachments or images." : undefined}
729
1444
  onChange={(e) => handleFromChange(e.target.value)}
730
1445
  >
731
- {mailboxes.map((mb) => (
1446
+ {fromOptions.map((mb) => (
732
1447
  <option key={mb.uid} value={mb.uid}>
733
1448
  {mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress}
734
1449
  {mb.ownerUserUid ? "" : " (shared)"}
@@ -878,8 +1593,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
878
1593
  <div className="flex items-center rounded-pill bg-primary text-white overflow-hidden">
879
1594
  <button
880
1595
  type="button"
881
- onClick={() => handleSend()}
882
- disabled={!draft || sending || !cryptoContextReady}
1596
+ onClick={() => void submit(false)}
1597
+ disabled={!draft || sending || closing || !cryptoContextReady}
883
1598
  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"
884
1599
  >
885
1600
  {sending ? "Sending…" : "Send"}
@@ -890,7 +1605,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
890
1605
  aria-label="Send later"
891
1606
  aria-haspopup="true"
892
1607
  aria-expanded={schedulePickerOpen}
893
- disabled={!draft || sending || !cryptoContextReady}
1608
+ disabled={!draft || sending || closing || !cryptoContextReady}
894
1609
  onClick={() => setSchedulePickerOpen((o) => !o)}
895
1610
  className="py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
896
1611
  >
@@ -914,17 +1629,24 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
914
1629
  <input type="file" multiple disabled={!draft} onChange={handleFilesSelected} className="sr-only" />
915
1630
  </label>
916
1631
 
1632
+ <span role="status" aria-live="polite" className="ml-auto text-xs text-text-muted">
1633
+ {SAVE_STATUS_LABEL[saveStatus]}
1634
+ </span>
1635
+
917
1636
  <button
918
1637
  type="button"
919
1638
  aria-label="Discard draft"
920
1639
  title="Discard draft"
921
- onClick={onClose}
922
- className="ml-auto w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
1640
+ onClick={handleDiscard}
1641
+ disabled={sending || closing}
1642
+ 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"
923
1643
  >
924
1644
  <HiOutlineTrash size={18} />
925
1645
  </button>
926
1646
  </div>
927
1647
  </div>
928
1648
  </div>
1649
+ {discardModal}
1650
+ </>
929
1651
  );
930
1652
  }