@rapidmx/web-client 0.10.0 → 0.11.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 (310) hide show
  1. package/README.md +183 -2
  2. package/apps/admin/_layout.tsx +8 -2
  3. package/apps/admin/index.tsx +7 -1
  4. package/apps/admin/ingest-queue/index.tsx +2 -1
  5. package/apps/admin/mailboxes/[uid].tsx +23 -10
  6. package/apps/admin/quarantine/index.tsx +2 -1
  7. package/apps/admin/signing-certificates/index.tsx +343 -0
  8. package/apps/escrow/_layout.tsx +8 -2
  9. package/apps/shared/appearance/AppearanceForm.tsx +392 -0
  10. package/apps/shared/appearance/AppearanceHead.tsx +49 -0
  11. package/apps/shared/appearance/AppearanceProvider.tsx +470 -0
  12. package/apps/shared/appearance/ColorField.tsx +107 -0
  13. package/apps/shared/appearance/appearanceCache.ts +106 -0
  14. package/apps/shared/appearance/appearanceContext.ts +77 -0
  15. package/apps/shared/appearance/bootScript.ts +35 -0
  16. package/apps/shared/appearance/color.ts +169 -0
  17. package/apps/shared/appearance/photo.ts +88 -0
  18. package/apps/shared/appearance/resolvedTheme.ts +67 -0
  19. package/apps/shared/appearance/theme.ts +355 -0
  20. package/apps/shared/components/admin/layout/AdminShell.tsx +22 -10
  21. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +107 -60
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +24 -0
  23. package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
  24. package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
  25. package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
  26. package/apps/shared/components/contacts/ContactForm.tsx +11 -5
  27. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
  28. package/apps/shared/components/contacts/ContactsToolbar.tsx +25 -52
  29. package/apps/shared/components/contacts/layout/ContactsShell.tsx +10 -5
  30. package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
  31. package/apps/shared/components/layout/AppShell.tsx +98 -32
  32. package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
  33. package/apps/shared/components/layout/RailIcon.tsx +100 -0
  34. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
  35. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
  36. package/apps/shared/components/layout/UserMenu.tsx +75 -6
  37. package/apps/shared/components/mail/ConversationList.tsx +4 -2
  38. package/apps/shared/components/mail/ConversationThreadPane.tsx +487 -475
  39. package/apps/shared/components/mail/LazyReadingPane.tsx +14 -16
  40. package/apps/shared/components/mail/MailSelectionBar.tsx +2 -10
  41. package/apps/shared/components/mail/MessageDetailPane.tsx +366 -287
  42. package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
  43. package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
  44. package/apps/shared/components/mail/compose/ComposeContext.tsx +35 -2
  45. package/apps/shared/components/mail/compose/ComposeWindow.tsx +239 -437
  46. package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
  47. package/apps/shared/components/mail/layout/MailShell.tsx +39 -25
  48. package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
  49. package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
  50. package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
  51. package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
  52. package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
  53. package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
  54. package/apps/shared/components/mail/reading/color.ts +245 -0
  55. package/apps/shared/components/mail/reading/frameControl.ts +142 -0
  56. package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
  57. package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
  58. package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
  59. package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
  60. package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
  61. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
  62. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  63. package/apps/shared/components/sharing/PrincipalPicker.tsx +145 -0
  64. package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
  65. package/apps/shared/components/tasks/layout/TasksShell.tsx +9 -5
  66. package/apps/shared/mail/folderCounts.ts +9 -3
  67. package/apps/shared/mail/folderTree.ts +143 -0
  68. package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
  69. package/apps/shared/mail/outbox/composeBridge.ts +69 -0
  70. package/apps/shared/mail/outbox/outboxState.ts +88 -0
  71. package/apps/shared/mail/outbox/pendingSends.ts +146 -0
  72. package/apps/shared/mail/outbox/sendDecision.ts +157 -0
  73. package/apps/shared/mail/outbox/sendJob.ts +427 -0
  74. package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
  75. package/apps/shared/mail/outbox/sendState.ts +35 -0
  76. package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
  77. package/apps/shared/mail/useMailConnection.ts +81 -28
  78. package/apps/shared/mail/useMailLiveUpdates.ts +55 -10
  79. package/apps/shared/mail/useNewMailNotifications.ts +47 -32
  80. package/apps/shared/navigation/AppRouter.tsx +2 -2
  81. package/apps/shared/notifications/NotificationCenter.tsx +215 -0
  82. package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
  83. package/apps/shared/notifications/apiErrors.ts +98 -0
  84. package/apps/shared/notifications/headerOffset.ts +35 -0
  85. package/apps/shared/notifications/pushStatus.ts +56 -0
  86. package/apps/shared/notifications/store.ts +549 -0
  87. package/apps/shared/notifications/systemErrors.ts +63 -0
  88. package/apps/shared/notifications/useNotifications.ts +41 -0
  89. package/apps/shared/signing/enrollmentStorage.ts +33 -0
  90. package/apps/shared/signing/enrollmentTracker.ts +385 -0
  91. package/apps/shared/signing/enrollmentView.ts +251 -0
  92. package/apps/shared/signing/signingInfo.ts +58 -0
  93. package/apps/shared/signing/useNow.ts +19 -0
  94. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
  95. package/apps/shared/styles/app.css +80 -0
  96. package/apps/www/_layout.tsx +8 -2
  97. package/apps/www/_routes.ts +1 -0
  98. package/apps/www/calendar/index.tsx +9 -14
  99. package/apps/www/contacts/[uid].tsx +5 -8
  100. package/apps/www/contacts/index.tsx +587 -584
  101. package/apps/www/index.tsx +38 -27
  102. package/apps/www/messages/[uid].tsx +3 -1
  103. package/apps/www/settings/appearance/index.tsx +21 -0
  104. package/apps/www/settings/auto-reply/index.tsx +3 -7
  105. package/apps/www/settings/encryption/index.tsx +151 -116
  106. package/apps/www/settings/filters/[uid].tsx +3 -1
  107. package/apps/www/settings/filters/new/index.tsx +3 -1
  108. package/apps/www/settings/privacy/index.tsx +3 -8
  109. package/apps/www/settings/read-receipts/index.tsx +3 -7
  110. package/apps/www/settings/sharing/index.tsx +44 -65
  111. package/apps/www/settings/signatures/[uid].tsx +3 -1
  112. package/apps/www/settings/signatures/new/index.tsx +3 -2
  113. package/apps/www/tasks/index.tsx +11 -16
  114. package/dist/apps/admin/_layout.d.ts +5 -1
  115. package/dist/apps/admin/_layout.js +3 -2
  116. package/dist/apps/admin/index.js +3 -2
  117. package/dist/apps/admin/ingest-queue/index.js +2 -1
  118. package/dist/apps/admin/mailboxes/[uid].js +9 -6
  119. package/dist/apps/admin/quarantine/index.js +2 -1
  120. package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
  121. package/dist/apps/admin/signing-certificates/index.js +161 -0
  122. package/dist/apps/escrow/_layout.d.ts +5 -1
  123. package/dist/apps/escrow/_layout.js +3 -2
  124. package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
  125. package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
  126. package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
  127. package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
  128. package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
  129. package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
  130. package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
  131. package/dist/apps/shared/appearance/ColorField.js +40 -0
  132. package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
  133. package/dist/apps/shared/appearance/appearanceCache.js +80 -0
  134. package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
  135. package/dist/apps/shared/appearance/appearanceContext.js +29 -0
  136. package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
  137. package/dist/apps/shared/appearance/bootScript.js +33 -0
  138. package/dist/apps/shared/appearance/color.d.ts +65 -0
  139. package/dist/apps/shared/appearance/color.js +132 -0
  140. package/dist/apps/shared/appearance/photo.d.ts +17 -0
  141. package/dist/apps/shared/appearance/photo.js +79 -0
  142. package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
  143. package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
  144. package/dist/apps/shared/appearance/theme.d.ts +111 -0
  145. package/dist/apps/shared/appearance/theme.js +251 -0
  146. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  147. package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
  148. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
  149. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
  150. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
  151. package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
  152. package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
  153. package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
  154. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
  155. package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
  156. package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
  157. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  158. package/dist/apps/shared/components/contacts/ContactsToolbar.js +24 -9
  159. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -1
  160. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
  161. package/dist/apps/shared/components/layout/AppShell.d.ts +6 -1
  162. package/dist/apps/shared/components/layout/AppShell.js +59 -13
  163. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
  164. package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
  165. package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
  166. package/dist/apps/shared/components/layout/RailIcon.js +85 -0
  167. package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
  168. package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
  169. package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
  170. package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
  171. package/dist/apps/shared/components/layout/UserMenu.d.ts +7 -1
  172. package/dist/apps/shared/components/layout/UserMenu.js +41 -7
  173. package/dist/apps/shared/components/mail/ConversationList.js +3 -2
  174. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +2 -1
  175. package/dist/apps/shared/components/mail/ConversationThreadPane.js +42 -20
  176. package/dist/apps/shared/components/mail/LazyReadingPane.js +9 -8
  177. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +3 -4
  178. package/dist/apps/shared/components/mail/MailSelectionBar.js +2 -3
  179. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +18 -7
  180. package/dist/apps/shared/components/mail/MessageDetailPane.js +86 -41
  181. package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
  182. package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
  183. package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
  184. package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
  185. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +8 -0
  186. package/dist/apps/shared/components/mail/compose/ComposeContext.js +28 -3
  187. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +2 -1
  188. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +229 -328
  189. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
  190. package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
  191. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +2 -0
  192. package/dist/apps/shared/components/mail/layout/MailShell.js +20 -14
  193. package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
  194. package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
  195. package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
  196. package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
  197. package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
  198. package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
  199. package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
  200. package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
  201. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
  202. package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
  203. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
  204. package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
  205. package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
  206. package/dist/apps/shared/components/mail/reading/color.js +201 -0
  207. package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
  208. package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
  209. package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
  210. package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
  211. package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
  212. package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
  213. package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
  214. package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
  215. package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
  216. package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
  217. package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
  218. package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
  219. package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
  220. package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
  221. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  222. package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +26 -0
  223. package/dist/apps/shared/components/sharing/PrincipalPicker.js +77 -0
  224. package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
  225. package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -1
  226. package/dist/apps/shared/mail/folderCounts.d.ts +1 -1
  227. package/dist/apps/shared/mail/folderCounts.js +9 -3
  228. package/dist/apps/shared/mail/folderTree.d.ts +55 -0
  229. package/dist/apps/shared/mail/folderTree.js +116 -0
  230. package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
  231. package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
  232. package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
  233. package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
  234. package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
  235. package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
  236. package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
  237. package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
  238. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
  239. package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
  240. package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
  241. package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
  242. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
  243. package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
  244. package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
  245. package/dist/apps/shared/mail/outbox/sendState.js +8 -0
  246. package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
  247. package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
  248. package/dist/apps/shared/mail/useMailConnection.d.ts +9 -8
  249. package/dist/apps/shared/mail/useMailConnection.js +64 -22
  250. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +17 -4
  251. package/dist/apps/shared/mail/useMailLiveUpdates.js +36 -10
  252. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +4 -10
  253. package/dist/apps/shared/mail/useNewMailNotifications.js +43 -23
  254. package/dist/apps/shared/navigation/AppRouter.js +2 -2
  255. package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
  256. package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
  257. package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
  258. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
  259. package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
  260. package/dist/apps/shared/notifications/apiErrors.js +86 -0
  261. package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
  262. package/dist/apps/shared/notifications/headerOffset.js +33 -0
  263. package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
  264. package/dist/apps/shared/notifications/pushStatus.js +54 -0
  265. package/dist/apps/shared/notifications/store.d.ts +136 -0
  266. package/dist/apps/shared/notifications/store.js +416 -0
  267. package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
  268. package/dist/apps/shared/notifications/systemErrors.js +58 -0
  269. package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
  270. package/dist/apps/shared/notifications/useNotifications.js +18 -0
  271. package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
  272. package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
  273. package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
  274. package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
  275. package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
  276. package/dist/apps/shared/signing/enrollmentView.js +214 -0
  277. package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
  278. package/dist/apps/shared/signing/signingInfo.js +48 -0
  279. package/dist/apps/shared/signing/useNow.d.ts +2 -0
  280. package/dist/apps/shared/signing/useNow.js +18 -0
  281. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
  282. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
  283. package/dist/apps/shared/styles/app.css +80 -0
  284. package/dist/apps/www/_layout.d.ts +5 -1
  285. package/dist/apps/www/_layout.js +3 -2
  286. package/dist/apps/www/_routes.js +1 -0
  287. package/dist/apps/www/calendar/index.js +3 -4
  288. package/dist/apps/www/contacts/[uid].js +5 -7
  289. package/dist/apps/www/contacts/index.js +14 -21
  290. package/dist/apps/www/index.js +37 -19
  291. package/dist/apps/www/messages/[uid].js +3 -1
  292. package/dist/apps/www/settings/appearance/index.d.ts +4 -0
  293. package/dist/apps/www/settings/appearance/index.js +13 -0
  294. package/dist/apps/www/settings/auto-reply/index.js +3 -6
  295. package/dist/apps/www/settings/encryption/index.js +103 -95
  296. package/dist/apps/www/settings/filters/[uid].js +3 -1
  297. package/dist/apps/www/settings/filters/new/index.js +3 -1
  298. package/dist/apps/www/settings/privacy/index.js +5 -8
  299. package/dist/apps/www/settings/read-receipts/index.js +3 -6
  300. package/dist/apps/www/settings/sharing/index.js +19 -33
  301. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  302. package/dist/apps/www/settings/signatures/new/index.js +3 -2
  303. package/dist/apps/www/tasks/index.js +8 -14
  304. package/package.json +2 -2
  305. package/apps/shared/components/mail/NewMailToasts.tsx +0 -141
  306. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
  307. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +0 -19
  308. package/dist/apps/shared/components/mail/NewMailToasts.js +0 -55
  309. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
  310. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
@@ -6,17 +6,19 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  import { useEffect, useRef, useState } from "react";
7
7
  import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevronDown, HiOutlineLockClosed, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
8
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
- import { assembleDraft, assembleDraftRaw, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, sendMessage, setMessageRequestReceipt, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import { assembleDraft, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
10
10
  import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
11
- import { describeSendFailure } from "@rapidmx/react-shared/mail/sendFailure.js";
12
11
  import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
13
12
  import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
14
- import { decideMessageEncryption, resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
13
+ import { resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
15
14
  import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
16
15
  import { findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
17
16
  import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
18
- import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
19
17
  import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
18
+ import { notify } from "../../../notifications/store.js";
19
+ import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
20
+ import { decideSend } from "../../../mail/outbox/sendDecision.js";
21
+ import { sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
20
22
  import { useCompose } from "./ComposeContext.js";
21
23
  import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
22
24
  import { SHORTCUTS } from "../../../keyboard/keymap.js";
@@ -28,48 +30,25 @@ import RecipientInput from "./RecipientInput.js";
28
30
  import { parseRecipientList } from "./recipients.js";
29
31
  import RichTextEditor from "./RichTextEditor.js";
30
32
  import ScheduleSendPicker from "./ScheduleSendPicker.js";
31
- import SendFailureAlert from "./SendFailureAlert.js";
32
33
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
33
34
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
34
35
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
35
36
  /** Debounce between the last edit and the draft autosave. */
36
37
  export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
37
- /** Automatic retries of a failed mailbox/encryption-policy load; after the last one only "Retry" tries again. */
38
- export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000];
39
- /** Signed/encrypted bodies are built client-side from the editor's HTML verbatim, so an inline image
40
- * (an uploaded attachment previewed via its content URL, or a `cid:` reference) would never be carried -
41
- * only `assembleDraft()`'s server-side plaintext path rewrites those into real MIME parts. */
42
- const INLINE_IMAGE_PATTERN = /<img\b[^>]*\bsrc\s*=\s*["']?(?:cid:|[^"'\s>]*\/mail\/attachments\/[^"'\s>]+\/content)/i;
43
- const KEYS_LOCKED_SIGN_MESSAGE = "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.";
44
- const KEYS_LOCKED_ENCRYPT_MESSAGE = "This message can't be encrypted right now - your encryption key is locked. Unlock and send again, or send it without encryption.";
45
- const POLICY_UNAVAILABLE_MESSAGE = "Your encryption settings couldn't be loaded, so this message can't be encrypted right now. Try again, or send it without encryption.";
46
- const BCC_ENCRYPTED_MESSAGE = "Bcc recipients can't be used with encrypted messages. Remove Bcc recipients or turn off encryption.";
47
- const LOOKUP_UNAVAILABLE_MESSAGE = "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.";
38
+ /** Automatic, silent retries of a failed mailbox/encryption-policy (or recipient key) load, one entry per retry - a flapping gateway is waited out
39
+ * in the background, without a word to the user. */
40
+ export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000, 60000];
48
41
  /** Thrown by `deleteDraft()` when the server copy is no longer this window's draft (sent or scheduled elsewhere). */
49
42
  const NO_LONGER_A_DRAFT_MESSAGE = "This message was already sent or scheduled from another window, so it wasn't deleted.";
50
- /** The server refuses signed/encrypted mail whose From display name differs from its own safe form, which drops a
51
- * name containing `@` (or a look-alike) or a line break. */
52
- const UNSAFE_DISPLAY_NAME_PATTERN = /[@@﹫\r\n]/;
53
43
  /** Whether `fresh` (a re-read of a draft) is still an unsent draft in `folderUid`: never delete a message another
54
44
  * window has since sent, scheduled, or started sending. */
55
45
  function isStillDraft(fresh, folderUid) {
56
46
  return fresh.folderUid === folderUid && !fresh.scheduledSendTime && !fresh.scheduledSendLeaseExpiresAt && !fresh.scheduledSendRelayedAt;
57
47
  }
58
- /** Any tier of `policy` auto-encrypts - only then can a message with recipients still to be added turn out encrypted
59
- * without the user asking. */
60
- function policyCanAutoEncrypt(policy) {
61
- return [policy.encryptSameOrg, policy.encryptFederated, policy.encryptExternal].includes("automatic");
62
- }
63
48
  const SAVE_STATUS_LABEL = { idle: "", saving: "Saving…", saved: "Draft saved", error: "Couldn't save draft" };
64
49
  const DISCARD_TITLE = "Discard this draft?";
50
+ /** Shown only for a message that is actually headed for encryption (asked for, or applied by the loaded policy) - see `encryptionRequirement.ts`. */
65
51
  const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so closing this window discards what you've written.";
66
- const CHECKING_CLOSE_MESSAGE = "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.";
67
- const CRYPTO_UNAVAILABLE_MESSAGE = "Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
68
- const NO_RECIPIENTS_CLOSE_MESSAGE = "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.";
69
- const SEND_FAILED_TITLE = "This message wasn't sent";
70
- function sendFailedCloseMessage(reason) {
71
- return `Sending it failed (${reason.replace(/\.$/, "")}). Closing keeps it as a draft, unsent. Keep editing to try again, or discard it.`;
72
- }
73
52
  const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
74
53
  const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
75
54
  const MIN_WIDTH = 320;
@@ -104,7 +83,7 @@ function HeaderButton({ label, onClick, icon: Icon, disabled, shortcut, }) {
104
83
  * once.
105
84
  */
106
85
  export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS, }) {
107
- const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late } = session;
86
+ const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late, resume } = session;
108
87
  // What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
109
88
  // only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
110
89
  const baselineTo = late?.to ?? initialTo;
@@ -121,7 +100,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
121
100
  const [mailboxes, setMailboxes] = useState([]);
122
101
  // Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
123
102
  // Drafts folder - so once anything has been uploaded the sender can no longer be switched.
124
- const [hasUploads, setHasUploads] = useState(false);
103
+ const [hasUploads, setHasUploads] = useState(!!resume && resume.attachments.length > 0);
125
104
  const isMobile = useIsMobile();
126
105
  const { requestUnlock } = useUnlockPrompt();
127
106
  const { openCompose } = useCompose();
@@ -132,26 +111,29 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
132
111
  const windowRef = useRef(null);
133
112
  const [draftsFolderUid, setDraftsFolderUid] = useState();
134
113
  const [folderError, setFolderError] = useState(null);
135
- const [draft, setDraft] = useState(null);
114
+ // A window re-opened around a message that was already composed (a failed send's "Open draft") continues that server draft and starts with
115
+ // every field as it was typed; its body is not seeded from a signature or a quote.
116
+ const [draft, setDraft] = useState(resume?.draft ?? null);
136
117
  // Drafts replaced by a From switch, awaiting deletion until their replacement has actually been created.
137
118
  const supersededDraftsRef = useRef([]);
138
119
  const [draftError, setDraftError] = useState(null);
139
120
  const [expanded, setExpanded] = useState(false);
140
121
  const [manualSize, setManualSize] = useState(null);
141
- const [to, setTo] = useState(initialTo ?? "");
142
- const [cc, setCc] = useState(initialCc ?? "");
143
- const [bcc, setBcc] = useState("");
144
- const [showCcBcc, setShowCcBcc] = useState(!!initialCc);
145
- const [subject, setSubject] = useState(initialSubject ?? "");
146
- const [html, setHtml] = useState("");
147
- const [contentReady, setContentReady] = useState(false);
148
- const [attachments, setAttachments] = useState([]);
122
+ const [to, setTo] = useState(resume?.to ?? initialTo ?? "");
123
+ const [cc, setCc] = useState(resume?.cc ?? initialCc ?? "");
124
+ const [bcc, setBcc] = useState(resume?.bcc ?? "");
125
+ const [showCcBcc, setShowCcBcc] = useState(!!(resume ? resume.cc || resume.bcc : initialCc));
126
+ const [subject, setSubject] = useState(resume?.subject ?? initialSubject ?? "");
127
+ const [html, setHtml] = useState(resume?.html ?? "");
128
+ const [contentReady, setContentReady] = useState(!!resume);
129
+ const [attachments, setAttachments] = useState(resume?.attachments ?? []);
149
130
  const [attachError, setAttachError] = useState(null);
131
+ // A problem with the message as composed (no recipient, an attachment that can't be signed): shown here, and the window stays open. What
132
+ // goes wrong after Send was accepted is a pop-up instead (see `sendJob.ts`).
150
133
  const [sendError, setSendError] = useState(null);
151
- // The technical facts behind `sendError` (per-recipient SMTP results, a transport error), when the server gave any.
152
- const [sendDetails, setSendDetails] = useState([]);
153
- const [sending, setSending] = useState(false);
154
- const [requestReceipt, setRequestReceipt] = useState(false);
134
+ // Send was pressed while an attachment was still uploading: the window waits (and says so) rather than send without it.
135
+ const [waitingForUploads, setWaitingForUploads] = useState(false);
136
+ const [requestReceipt, setRequestReceipt] = useState(!!resume?.requestReceipt);
155
137
  const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
156
138
  const scheduleButtonRef = useRef(null);
157
139
  const [mailbox, setMailbox] = useState(null);
@@ -160,11 +142,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
160
142
  // Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
161
143
  // `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
162
144
  // still turn it back on; this only changes the default.
163
- const [signEnabled, setSignEnabled] = useState(!suppressSigning);
145
+ const [signEnabled, setSignEnabled] = useState(resume ? resume.signEnabled : !suppressSigning);
164
146
  // A reply to or forward of an encrypted message starts (and, after a From switch, restarts) with encryption
165
147
  // requested - see `OpenComposeInput.encrypt`.
166
- const [encryptRequested, setEncryptRequested] = useState(!!initialEncrypt);
167
- const [encryptionBlocked, setEncryptionBlocked] = useState(null);
148
+ const [encryptRequested, setEncryptRequested] = useState(resume ? resume.encryptRequested : !!initialEncrypt);
168
149
  // Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
169
150
  // recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
170
151
  // just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
@@ -174,16 +155,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
174
155
  // look one up twice), and a counter bumped by a From switch so a lookup for the previous sender is dropped.
175
156
  const lookupsInFlightRef = useRef(new Set());
176
157
  const discoveryGenerationRef = useRef(0);
177
- // A failed lookup stores no status (it would read as "no key" and let a message that must be encrypted autosave
178
- // or send as plaintext): it's retried with the same backoff as the mailbox/policy load, and once those retries
179
- // run out the address is "exhausted" - shown as a check that couldn't be done, with Retry.
158
+ // A failed lookup stores no status: the recipient is simply "not known to be encryptable" (fail open - see `encryptionRequirement.ts`), and
159
+ // the lookup is retried in the background with the same backoff as the mailbox/policy load, without a word to the user.
180
160
  const lookupFailuresRef = useRef(new Map());
181
161
  const lookupRetryTimersRef = useRef(new Set());
182
162
  const [lookupRetryToken, setLookupRetryToken] = useState(0);
183
- const [exhaustedLookups, setExhaustedLookups] = useState({});
184
- // A send refused because signing/encryption was wanted but can't happen right now (keys locked, the
185
- // encryption policy/mailbox couldn't be loaded, or Bcc recipients on an encrypted message) - never a
186
- // silent downgrade to plaintext; the user has to explicitly pick the override.
163
+ // A send refused *before it left* because signing/encryption was asked for (or the loaded policy applies it) but can't happen right now
164
+ // (keys locked, Bcc recipients on an encrypted message, a recipient without a key) - never a silent downgrade to plaintext; the user
165
+ // has to explicitly pick the override.
187
166
  const [securityBlock, setSecurityBlock] = useState(null);
188
167
  // The schedule time of the send that got blocked (undefined for an immediate send), so the banners'
189
168
  // override buttons replay the same kind of send the user originally asked for.
@@ -204,8 +183,11 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
204
183
  const lastSavedRef = useRef(null);
205
184
  /** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
206
185
  const pendingSaveRef = useRef(false);
207
- /** Mirrors `sending` for the page-leave/sign-out flushes, which run outside React's render cycle. */
208
- const sendingRef = useRef(false);
186
+ /** Send was pressed and is being validated/handed over: a second press (a double click, the shortcut held or repeated) does nothing. */
187
+ const submittingRef = useRef(false);
188
+ /** Attachment/inline-image uploads on the wire, and who is waiting for them to finish (Send pressed meanwhile). */
189
+ const uploadsInFlightRef = useRef(0);
190
+ const uploadWaitersRef = useRef([]);
209
191
  /** The latest save request, if any - each save is chained after the one before it. Always settles (never
210
192
  * rejects), to the saved message, or `undefined` when that save failed. */
211
193
  const saveInFlightRef = useRef(Promise.resolve(undefined));
@@ -213,13 +195,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
213
195
  const finishedRef = useRef(false);
214
196
  // A mailbox with no keys enrolled yet just means sign/encrypt stay unavailable for this compose session -
215
197
  // encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
216
- // `cryptoContextReady` (below) gates Send/Send-later until both calls have settled either way, so a
217
- // send that happens to race this fetch can't silently skip encryption the spec says should apply.
218
- // A failed load is retried with backoff (`cryptoRetryDelaysMs`), then on demand ("Retry"): until both have
219
- // loaded, whether this message must be encrypted is unknown, so it isn't autosaved (see `autosaveSuppressed`).
220
- const [cryptoContextReady, setCryptoContextReady] = useState(false);
221
- const [cryptoLoadFailed, setCryptoLoadFailed] = useState(false);
222
- const [cryptoRetryToken, setCryptoRetryToken] = useState(0);
198
+ // The mailbox and the encryption policy load in the background and *nothing waits for them*: until both have loaded, the message is treated as
199
+ // not encrypted - its draft saves, Close and Send work, and no message about it is shown (see `encryptionRequirement.ts`). A failed load is
200
+ // retried silently with backoff (`cryptoRetryDelaysMs`); if it later turns out the message must be encrypted, `encryptionDecided` (below) starts
201
+ // to apply - see the transition rules there.
223
202
  useEffect(() => {
224
203
  let cancelled = false;
225
204
  listMailboxes({ limit: 100 })
@@ -254,8 +233,6 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
254
233
  let mailboxLoaded = mailbox?.uid === mailboxUid;
255
234
  let policyLoaded = !!encryptionPolicy;
256
235
  let retries = 0;
257
- setCryptoContextReady(false);
258
- setCryptoLoadFailed(false);
259
236
  async function load() {
260
237
  await Promise.all([
261
238
  mailboxLoaded ||
@@ -273,13 +250,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
273
250
  }
274
251
  }, () => undefined),
275
252
  ]);
276
- if (cancelled) {
277
- return;
278
- }
279
- const failed = !mailboxLoaded || !policyLoaded;
280
- setCryptoContextReady(true);
281
- setCryptoLoadFailed(failed);
282
- if (failed && retries < cryptoRetryDelaysMs.length) {
253
+ if (!cancelled && (!mailboxLoaded || !policyLoaded) && retries < cryptoRetryDelaysMs.length) {
283
254
  timer = setTimeout(() => void load(), cryptoRetryDelaysMs[retries++]);
284
255
  }
285
256
  }
@@ -288,7 +259,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
288
259
  cancelled = true;
289
260
  clearTimeout(timer);
290
261
  };
291
- }, [mailboxUid, cryptoRetryToken]);
262
+ }, [mailboxUid]);
292
263
  useEffect(() => {
293
264
  if (!mailboxUid) {
294
265
  return;
@@ -315,14 +286,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
315
286
  * Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
316
287
  * caching the result so the small per-recipient indicator below the recipient fields can update
317
288
  * without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
318
- * and not the same lookup `assembleForSend()` performs again at send time (deliberately: this is a
289
+ * and not the lookup the background send (`sendJob.ts`) performs again at send time (deliberately: this is a
319
290
  * best-effort UI hint, not something a stale value here should be trusted to skip at send time).
320
291
  * A mailbox/policy fetch that hasn't resolved yet just leaves that address with no indicator - the effect
321
292
  * below runs it again for every current recipient once both have loaded. A failed lookup leaves it without
322
- * a status too, and is retried after `cryptoRetryDelaysMs` (then only on blur, Close or Retry).
293
+ * a status too, and is retried silently after `cryptoRetryDelaysMs` (then only on blur).
323
294
  *
324
- * The statuses also drive autosave: a recipient without one yet (lookup pending, failed or never run) or a
325
- * decision that would auto-encrypt keeps the plaintext draft save off (see `autosaveSuppressed`).
295
+ * The statuses also drive `encryptionRequirement.ts`: only when every recipient has one, and the loaded policy
296
+ * auto-encrypts for all of them, does the message turn into one that is never saved as a plaintext draft.
326
297
  */
327
298
  function checkRecipientDiscovery(addresses) {
328
299
  if (!mailbox || !encryptionPolicy) {
@@ -351,12 +322,11 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
351
322
  });
352
323
  }
353
324
  }
354
- /** Schedules the next automatic retry of a failed lookup, or marks the address exhausted once they've run out. */
325
+ /** Schedules the next automatic, silent retry of a failed lookup (until the backoff has run out; a later blur tries again). */
355
326
  function noteLookupFailed(address) {
356
327
  const failures = (lookupFailuresRef.current.get(address) ?? 0) + 1;
357
328
  lookupFailuresRef.current.set(address, failures);
358
329
  if (failures > cryptoRetryDelaysMs.length) {
359
- setExhaustedLookups((prev) => ({ ...prev, [address]: true }));
360
330
  return;
361
331
  }
362
332
  const timer = setTimeout(() => {
@@ -375,7 +345,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
375
345
  useEffect(() => {
376
346
  checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
377
347
  }, [mailbox, encryptionPolicy, lookupRetryToken]);
378
- // Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
348
+ // Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it (not for a window re-opened around a composed message)
379
349
  // plus any quoted original message (laid out by `buildComposeBodyHtml()`: an empty first paragraph for the
380
350
  // caret, then the signature, then the quote), before `RichTextEditor` ever mounts (gated by `contentReady`
381
351
  // below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
@@ -523,9 +493,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
523
493
  discoveryGenerationRef.current += 1;
524
494
  lookupsInFlightRef.current = new Set();
525
495
  lookupFailuresRef.current = new Map();
526
- setExhaustedLookups({});
527
496
  setEncryptRequested(!!initialEncrypt);
528
- setEncryptionBlocked(null);
529
497
  setSecurityBlock(null);
530
498
  offeredCryptoRef.current = { sign: false, encrypt: false };
531
499
  setFromMailboxUid(nextMailboxUid);
@@ -581,6 +549,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
581
549
  setAttachError("Please wait for the draft to finish loading before inserting an image.");
582
550
  return null;
583
551
  }
552
+ uploadStarted();
584
553
  try {
585
554
  const attachment = await uploadAttachment(draft.uid, file);
586
555
  setHasUploads(true);
@@ -591,6 +560,21 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
591
560
  setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
592
561
  return null;
593
562
  }
563
+ finally {
564
+ uploadFinished();
565
+ }
566
+ }
567
+ /** Send waits for every upload started before it: see `submit()`. */
568
+ function uploadStarted() {
569
+ uploadsInFlightRef.current += 1;
570
+ }
571
+ function uploadFinished() {
572
+ uploadsInFlightRef.current -= 1;
573
+ if (uploadsInFlightRef.current === 0) {
574
+ for (const resolve of uploadWaitersRef.current.splice(0)) {
575
+ resolve();
576
+ }
577
+ }
594
578
  }
595
579
  /** Uploading an attachment bumps the draft's version server-side (it records `hasAttachments`), so a later
596
580
  * Discard would otherwise send a stale `?version=`. Best-effort: Discard also retries with the server's
@@ -613,201 +597,110 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
613
597
  const files = Array.from(e.target.files ?? []);
614
598
  e.target.value = "";
615
599
  setAttachError(null);
616
- for (const file of files) {
617
- try {
618
- const attachment = await uploadAttachment(draft.uid, file);
619
- setHasUploads(true);
620
- setAttachments((prev) => [...prev, attachment]);
621
- void refreshDraftVersion(draft.uid);
622
- }
623
- catch (err) {
624
- setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
625
- }
626
- }
627
- }
628
- /**
629
- * Builds this draft's final body — plaintext, signed-only, or signed+encrypted per
630
- * `specs/end-to-end_encryption.md` — then stores it via `assembleDraft()`/`assembleDraftRaw()`.
631
- * Shared by immediate and scheduled sends (and the banners' override buttons rendered below, via
632
- * `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
633
- *
634
- * Returns `"blocked"` (never throws for this case) whenever the message can't go out the way the user
635
- * asked for — never a silent downgrade to plaintext:
636
- * encryption was requested (or auto-applies) but not every recipient can currently be encrypted to -
637
- * the spec's "Multiple Recipients" all-or-nothing rule (sets `encryptionBlocked`); signing/encryption was
638
- * on offer this session but the keys have since locked, or the mailbox/encryption policy needed to decide
639
- * couldn't be loaded (sets `securityBlock`, and re-prompts the unlock dialog when the keys are merely
640
- * locked); or the message would be encrypted but has Bcc recipients - every recipient's certificate goes
641
- * into one shared envelope, which would disclose the Bcc list to every other recipient, and the send API
642
- * has no per-recipient-copy submission to avoid that (sets `securityBlock`).
643
- * Callers must stop (not send) when they get this back. `forcePlaintext` (the user's explicit choice
644
- * from one of those banners) skips every one of these checks and never encrypts; signing still
645
- * happens if it's currently possible.
646
- *
647
- * Discovery (`lookupKeys()`) and the resulting sign/encrypt decision only run at this, the final
648
- * pre-send step — `checkRecipientDiscovery()`'s on-blur badges are a best-effort hint only.
649
- */
650
- async function assembleForSend(toRecipients, ccRecipients, bccRecipients, forcePlaintext) {
651
- // Keys are read at the moment they're needed, and again after any await: they can lock (idle
652
- // timeout, sign-out elsewhere) mid-send, and a destroyed `UnlockedKeys` object must count as locked.
653
- // Only reachable once a draft exists, which itself requires `mailboxUid` to have resolved.
654
- function readKeys() {
655
- const stored = getUnlockedKeys(mailboxUid);
656
- const unlocked = stored?.destroyed ? undefined : stored;
657
- return {
658
- unlocked,
659
- // `mailbox` is required to build protected headers (own From address) whenever signing or
660
- // encrypting - not just guarded on the encryption branch below, since a signed-only message
661
- // needs it too.
662
- canSign: signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer,
663
- canEncryptSelf: !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer,
664
- };
665
- }
666
- let keys = readKeys();
667
- const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
668
- // The Sign toggle was shown (and is still checked), but signing can't actually happen any more.
669
- const signingLost = () => !forcePlaintext && signEnabled && offeredCryptoRef.current.sign && !keys.canSign;
670
- if (signingLost()) {
671
- return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
672
- }
673
- if (!forcePlaintext && !mailbox) {
674
- // The mailbox's enrolled keys are what say whether this message could be encrypted at all - with keys
675
- // that aren't unlocked in this session, a failed mailbox load would otherwise skip encryption silently.
676
- return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
677
- }
678
- let wantEncrypt = false;
679
- let recipientCertDers = [];
680
- // Entered whenever this message could be encrypted at all - including a mailbox with an enrolled
681
- // encryption key that this session never unlocked: policy may still auto-encrypt, and that must block
682
- // (and prompt to unlock) rather than quietly send plaintext.
683
- if (!forcePlaintext && (keys.canEncryptSelf || offeredCryptoRef.current.encrypt || hasEnrolledEncryptionKey)) {
684
- if (!encryptionPolicy) {
685
- // Without it, there's no telling whether policy auto-encrypts this message.
686
- return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
687
- }
688
- // Reachable only with `mailbox` loaded (checked above).
689
- const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
690
- const lookups = await Promise.all(allRecipients.map((r) => lookupKeys(mailboxUid, r.address).then((lookup) => ({ lookup }), () => null)));
691
- keys = readKeys();
692
- if (signingLost()) {
693
- return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
694
- }
695
- // A failed lookup isn't "no key": treating it as one could send plaintext that policy would encrypt.
696
- if (lookups.some((result) => !result) && (encryptRequested || policyCanAutoEncrypt(encryptionPolicy))) {
697
- return blockSend(LOOKUP_UNAVAILABLE_MESSAGE, "Send without encryption", false);
698
- }
699
- const statuses = allRecipients.map((r, i) => resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]?.lookup));
700
- const decision = decideMessageEncryption(statuses);
701
- wantEncrypt = encryptRequested || decision.autoEncrypt;
702
- if (wantEncrypt) {
703
- if (!keys.canEncryptSelf) {
704
- return blockSend(KEYS_LOCKED_ENCRYPT_MESSAGE, "Send without encryption", !keys.unlocked);
705
- }
706
- if (!decision.canEncryptAll) {
707
- setEncryptionBlocked(decision.blockedRecipients);
708
- return "blocked";
600
+ uploadStarted();
601
+ try {
602
+ for (const file of files) {
603
+ try {
604
+ const attachment = await uploadAttachment(draft.uid, file);
605
+ setHasUploads(true);
606
+ setAttachments((prev) => [...prev, attachment]);
607
+ void refreshDraftVersion(draft.uid);
709
608
  }
710
- if (bccRecipients.length > 0) {
711
- return blockSend(BCC_ENCRYPTED_MESSAGE, "Send without encryption", false);
609
+ catch (err) {
610
+ setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
712
611
  }
713
- recipientCertDers = [keys.unlocked.encryptionCertDer, ...statuses.map((s) => fromBase64(s.encryptCert.publicKey))];
714
612
  }
715
613
  }
716
- const { canSign } = keys;
717
- if ((wantEncrypt || canSign) && attachments.length > 0) {
718
- // Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
719
- // clearer explanation than that route's generic 400 rather than via a round trip.
720
- throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
721
- }
722
- if ((wantEncrypt || canSign) && INLINE_IMAGE_PATTERN.test(html)) {
723
- throw new ApiRequestError("A signed or encrypted message cannot include inline images yet - remove them before sending.", 400);
724
- }
725
- if (!wantEncrypt && !canSign) {
726
- // See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
727
- // gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
728
- // client-side pass is done here either.
729
- return assembleDraft(draft.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject, html });
730
- }
731
- const domain = mailbox.primarySmtpAddress.split("@")[1] ?? "localhost";
732
- const displayName = mailbox.displayName && !UNSAFE_DISPLAY_NAME_PATTERN.test(mailbox.displayName) ? mailbox.displayName : "";
733
- const protectedHeaders = {
734
- from: displayName
735
- ? `"${displayName.replace(/"/g, '\\"')}" <${mailbox.primarySmtpAddress}>`
736
- : mailbox.primarySmtpAddress,
737
- to: toRecipients.map((r) => r.address).join(", "),
738
- cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
739
- date: new Date().toUTCString(),
740
- subject,
741
- messageId: `<${crypto.randomUUID()}@${domain}>`,
742
- };
743
- const bodyContentType = 'text/html; charset="utf-8"';
744
- const signing = canSign ? { certDer: keys.unlocked.signingCertDer, privateKey: keys.unlocked.signingPrivateKey } : undefined;
745
- // The S/MIME code (PKI.js and the ASN.1/X.509 libraries, over half a megabyte) is only needed here, to sign or
746
- // encrypt - a plain message never loads it, and it is kept out of the compose window's own chunk.
747
- const { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } = await import("@rapidmx/react-shared/crypto/smimeMessage.js");
748
- const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
749
- const mimePart = wantEncrypt
750
- ? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
751
- : await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing.certDer, signing.privateKey);
752
- const rawMime = assembleOutboundMime(outerHeaders, mimePart);
753
- return assembleDraftRaw(draft.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
754
- }
755
- /** Records why a send was refused (see `assembleForSend()`), re-prompting the unlock dialog when the
756
- * cause is keys that are enrolled but locked. */
757
- function blockSend(message, overrideLabel, keysLocked) {
758
- setSecurityBlock({ message, overrideLabel });
759
- if (keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
760
- void handleUnlockForCrypto();
614
+ finally {
615
+ uploadFinished();
761
616
  }
762
- return "blocked";
763
617
  }
764
618
  /**
765
- * Sends (or, given `scheduledSendTimeIso`, schedules) the draft. Same reasoning as `handleFilesSelected`
766
- * above: Send and the "Send later" caret are both `disabled` until `draft` resolves, so this is never
767
- * reachable with a null `draft`. A scheduled send assembles the draft first (same as an immediate send),
768
- * then sets `scheduledSendTime` on the *freshly assembled* copy before calling `sendMessage()` — `send()`
769
- * itself is what reads the field and defers relay into Outbox instead of sending immediately (see
770
- * `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`). A blocked send remembers its schedule
771
- * time so the banners' override buttons replay it as the same kind of send.
619
+ * Send (or, given `scheduledSendTimeIso`, Send later) - and get out of the way. The window is the place the message is *written*; nothing
620
+ * about getting it to the server is waited for here.
621
+ *
622
+ * 1. **Validate synchronously**, from what the window already knows (no request): a recipient is required; and if the message is one
623
+ * that must be signed or encrypted and can't be right now (keys locked, Bcc on an encrypted message, a recipient with no key, an
624
+ * attachment that can't be signed) that is shown right here and the window stays open - the sender fixes it or picks the override.
625
+ * An attachment still uploading is waited for, inline (Send says so), never left behind.
626
+ * 2. **Close immediately** (`onClose()`): `startSend()` (see `sendJob.ts`) takes over with a snapshot of the message. From here the only
627
+ * feedback is the Outbox indicator; a failure at any stage is a sticky pop-up with the reason and Retry / Open draft.
628
+ *
629
+ * Pressed twice (a double click, the shortcut repeated) it sends once: the first press sets `submittingRef` before any `await`, and
630
+ * `startSend()` itself refuses a draft that is already being sent. `forcePlaintext` is the override buttons' choice.
772
631
  */
773
632
  async function submit(forcePlaintext, scheduledSendTimeIso) {
774
- const toRecipients = parseAddresses(to);
775
- if (toRecipients.length === 0) {
633
+ if (submittingRef.current || finishedRef.current) {
634
+ return;
635
+ }
636
+ if (parseAddresses(latestRef.current.to).length === 0) {
776
637
  setSendError("At least one recipient is required.");
777
638
  return;
778
639
  }
779
- setSending(true);
780
- sendingRef.current = true;
640
+ submittingRef.current = true;
781
641
  setSendError(null);
782
- setSendDetails([]);
783
642
  setSecurityBlock(null);
784
- setEncryptionBlocked(null);
785
- pendingSaveRef.current = false;
786
643
  try {
787
- // An autosave already on the wire must never land after (and overwrite) the send's own assembly.
788
- await saveInFlightRef.current;
789
- const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), forcePlaintext);
790
- if (assembled === "blocked") {
644
+ if (uploadsInFlightRef.current > 0) {
645
+ setWaitingForUploads(true);
646
+ await new Promise((resolve) => uploadWaitersRef.current.push(resolve));
647
+ // The editor puts an image it has just uploaded into the message a moment after the upload resolves: let the window catch up.
648
+ await new Promise((resolve) => setTimeout(resolve, 0));
649
+ setWaitingForUploads(false);
650
+ }
651
+ const current = latestRef.current;
652
+ const request = {
653
+ draft: current.draft,
654
+ mailboxUid: current.mailboxUid,
655
+ mailbox: current.mailbox ?? undefined,
656
+ policy: current.encryptionPolicy ?? undefined,
657
+ toText: current.to,
658
+ ccText: current.cc,
659
+ bccText: current.bcc,
660
+ to: parseAddresses(current.to),
661
+ cc: parseAddresses(current.cc),
662
+ bcc: parseAddresses(current.bcc),
663
+ subject: current.subject,
664
+ html: current.html,
665
+ attachments: current.attachments,
666
+ requestReceipt: current.requestReceipt,
667
+ scheduledSendTime: scheduledSendTimeIso,
668
+ signEnabled: current.signEnabled,
669
+ offeredSign: offeredCryptoRef.current.sign,
670
+ offeredEncrypt: offeredCryptoRef.current.encrypt,
671
+ encryptRequested: current.encryptRequested,
672
+ forcePlaintext,
673
+ saved: saveInFlightRef.current,
674
+ };
675
+ if (request.to.length === 0) {
676
+ setSendError("At least one recipient is required.");
677
+ return;
678
+ }
679
+ const early = decideSend(sendDecisionInput(request, request.mailbox, request.policy, current.statuses, false));
680
+ if (early.action === "rejected") {
681
+ setSendError(early.message);
682
+ return;
683
+ }
684
+ if (early.action === "blocked") {
791
685
  setBlockedScheduleIso(scheduledSendTimeIso);
686
+ setSecurityBlock(early.block);
687
+ if (early.block.keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
688
+ void handleUnlockForCrypto();
689
+ }
792
690
  return;
793
691
  }
794
- // The draft now holds exactly this content (possibly signed/encrypted) - don't autosave a
795
- // plaintext copy over it if the rest of the send fails.
796
- lastSavedRef.current = `${assembled.uid}:${contentKey}`;
797
- const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
798
- await sendMessage(withReceipt.uid, scheduledSendTimeIso ? { scheduledSendTime: scheduledSendTimeIso } : undefined);
692
+ pendingSaveRef.current = false;
799
693
  finishedRef.current = true;
694
+ if (!startSend(request)) {
695
+ finishedRef.current = false;
696
+ setSendError("This message is already being sent.");
697
+ return;
698
+ }
800
699
  onClose();
801
700
  }
802
- catch (err) {
803
- // The window stays open with the draft exactly as it was; nothing here closes, discards or saves over it.
804
- const failure = describeSendFailure(err, scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.");
805
- setSendError(failure.message);
806
- setSendDetails(failure.lines);
807
- }
808
701
  finally {
809
- sendingRef.current = false;
810
- setSending(false);
702
+ submittingRef.current = false;
703
+ setWaitingForUploads(false);
811
704
  }
812
705
  }
813
706
  function handleScheduleSend(scheduledSendTimeIso) {
@@ -817,7 +710,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
817
710
  async function handleUnlockForCrypto() {
818
711
  try {
819
712
  // Only reachable when hasEnrolledSigningKey/hasEnrolledEncryptionKey found a real enrolled key
820
- // in mailbox.keys (the unlock button below, or blockSend()), so neither is empty/undefined here.
713
+ // in mailbox.keys (the unlock button below, or a blocked send()), so neither is empty/undefined here.
821
714
  await requestUnlock(mailbox.uid, mailbox.keys);
822
715
  setUnlockRefresh((n) => n + 1);
823
716
  }
@@ -832,7 +725,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
832
725
  * `latestRef` only once it actually runs, so it saves what was last typed (skipping the request when the
833
726
  * save before it already stored exactly that). Whether a save may happen at all is re-checked at that same
834
727
  * moment: while it waited, the message may have become one that must not be stored as plaintext (Encrypt
835
- * turned on, a recipient that auto-encrypts), or the window may have started sending or finished - then it
728
+ * turned on, a recipient that auto-encrypts), or the window may have been sent or finished - then it
836
729
  * resolves `{ skipped: true }` without a request (`message` then being the previous save's result). Never
837
730
  * rejects; `message` is `undefined` when the save failed.
838
731
  */
@@ -844,7 +737,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
844
737
  const outcome = (async () => {
845
738
  const before = await previous;
846
739
  const current = latestRef.current;
847
- if (current.autosaveSuppressed || sendingRef.current || finishedRef.current) {
740
+ if (current.autosaveSuppressed || finishedRef.current) {
848
741
  setSaveStatus("idle");
849
742
  // Still the newest copy the server holds, for the version a later delete needs.
850
743
  return { skipped: true, message: before };
@@ -963,39 +856,19 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
963
856
  }
964
857
  }
965
858
  /** "Close": keeps the draft (saving any unsaved edits first, and closing only once that save succeeded) -
966
- * unless there's nothing in it worth keeping, or it can't be saved because it's headed for encryption, or
967
- * whether it is can't be told yet (settings loading or unavailable, recipient lookups pending): those need
968
- * the same confirmation as a discard. A failed save keeps the window open with Discard / Keep editing.
969
- * Close and Discard are both disabled while a send is in progress: deleting the draft, or saving a
970
- * plaintext copy over the message the send just assembled, mid-send would lose or leak it. */
971
- async function handleClose(sendFailureAcknowledged = false) {
859
+ * unless there's nothing in it worth keeping, or it can't be saved because it's really headed for encryption
860
+ * (asked for, or applied by the loaded policy): that needs the same confirmation as a discard. Whatever is
861
+ * *unknown* (settings loading or failed, a lookup pending) is not a reason to hold the window: it is saved like any
862
+ * other message. A failed save keeps the window open with Discard / Keep editing. */
863
+ async function handleClose() {
972
864
  if (!hasUserContent) {
973
865
  void discardNow();
974
866
  return;
975
867
  }
976
- if (sendError && !sendFailureAcknowledged) {
977
- // Closing would keep the draft and say nothing, which reads as if it had been sent.
978
- setClosePrompt({ title: SEND_FAILED_TITLE, message: sendFailedCloseMessage(sendError), closeAnyway: true });
979
- return;
980
- }
981
868
  if (encryptionDecided) {
982
869
  setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
983
870
  return;
984
871
  }
985
- if (cryptoCheckUnavailable) {
986
- setClosePrompt({ title: DISCARD_TITLE, message: CRYPTO_UNAVAILABLE_MESSAGE, retry: true });
987
- return;
988
- }
989
- if (awaitingRecipients) {
990
- setClosePrompt({ title: DISCARD_TITLE, message: NO_RECIPIENTS_CLOSE_MESSAGE });
991
- return;
992
- }
993
- if (encryptionUndetermined) {
994
- // A recipient typed but never blurred has no lookup running yet.
995
- checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
996
- setClosePrompt({ title: DISCARD_TITLE, message: CHECKING_CLOSE_MESSAGE });
997
- return;
998
- }
999
872
  if (!draft || !contentReady) {
1000
873
  saveErrorRef.current = draftError ?? "the draft hasn't been created yet";
1001
874
  showSaveFailedPrompt();
@@ -1007,7 +880,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1007
880
  setClosing(false);
1008
881
  if (outcome.skipped) {
1009
882
  // It became a message that mustn't be saved as plaintext while waiting on an earlier save.
1010
- setClosePrompt({ title: DISCARD_TITLE, message: latestRef.current.encryptionDecided ? ENCRYPTED_CLOSE_MESSAGE : CHECKING_CLOSE_MESSAGE });
883
+ setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
1011
884
  return;
1012
885
  }
1013
886
  if (!outcome.message) {
@@ -1018,13 +891,6 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1018
891
  finishedRef.current = true;
1019
892
  onClose();
1020
893
  }
1021
- function retryCryptoContext() {
1022
- setClosePrompt(null);
1023
- setCryptoRetryToken((n) => n + 1);
1024
- lookupFailuresRef.current = new Map();
1025
- setExhaustedLookups({});
1026
- setLookupRetryToken((n) => n + 1);
1027
- }
1028
894
  // A plain read from keySession.ts's module-level session store, not React state - see that module's
1029
895
  // own doc comment. Cheap enough to read fresh on every render rather than caching in state.
1030
896
  const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
@@ -1056,27 +922,65 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1056
922
  const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
1057
923
  const baselineKey = JSON.stringify([baselineTo ?? "", baselineCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
1058
924
  const hasUserContent = contentKey !== baselineKey || hasUploads;
1059
- // A message headed for encryption is never autosaved: the draft would store its plaintext server-side,
1060
- // defeating end-to-end encryption. Until it's known not to be, it isn't either: while the mailbox (whose
1061
- // enrolled keys say whether encryption is possible at all) or the encryption policy hasn't loaded, and -
1062
- // whenever this message could be encrypted (an unlocked or enrolled encryption key) - while the policy is
1063
- // missing or any current recipient's lookup is still pending (or failed, or hasn't run) - and while there are no
1064
- // recipients at all when policy auto-encrypts some tier, since the first one added may turn encryption on.
925
+ // Whether this message is *really* headed for encryption - `encryptionRequirement.ts` decides, and it fails open: the message is plain (its
926
+ // draft saves, Close and Send work, nothing is shown) unless the user asked for encryption or the policy was loaded and applies it to every
927
+ // recipient (whose keys were all looked up). What has not loaded, or failed to, is not held against the message.
928
+ //
929
+ // Transitions, when a later load turns a plain message into an encrypted one (the policy arrives, a slow lookup answers, a recipient is
930
+ // added): (1) autosave stops at once (`autosaveSuppressed`); (2) a plaintext copy this window already saved is *replaced by an empty draft*
931
+ // (`scrubSavedDraft()` below) so the plaintext doesn't sit on the server; (3) Close asks first ("Encrypted messages aren't saved as drafts");
932
+ // (4) Send encrypts (or is refused, with the override, when it can't) - never silently plaintext. And back: when it stops being encrypted
933
+ // (a recipient removed) autosave simply resumes.
1065
934
  const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
1066
935
  const currentStatuses = currentAddresses.map((address) => recipientStatuses[address]);
1067
- const encryptionPossible = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
1068
- /** Could be auto-encrypted, but has no recipients yet to decide by. */
1069
- const awaitingRecipients = encryptionPossible && !!encryptionPolicy && currentAddresses.length === 0 && policyCanAutoEncrypt(encryptionPolicy);
1070
- const encryptionUndetermined = !cryptoContextReady || !mailbox || awaitingRecipients || (encryptionPossible && (!encryptionPolicy || currentStatuses.some((s) => !s)));
1071
- /** Known to be headed for encryption: requested, or policy auto-encrypts it. */
1072
- const encryptionDecided = encryptRequested || (!encryptionUndetermined && encryptionPossible && decideMessageEncryption(currentStatuses).autoEncrypt);
1073
- const autosaveSuppressed = encryptionDecided || encryptionUndetermined;
1074
- /** Loading the mailbox/policy failed (retries may still be pending) and this message's encryption hinges on it, or
1075
- * a current recipient's key lookup kept failing. */
1076
- const cryptoCheckUnavailable = (cryptoContextReady && cryptoLoadFailed && (!mailbox || (encryptionPossible && !encryptionPolicy))) ||
1077
- (encryptionPossible && currentAddresses.some((address) => exhaustedLookups[address] && !recipientStatuses[address]));
1078
- const latestRef = useRef({ draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided });
1079
- latestRef.current = { draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided };
936
+ const hasEncryptionKey = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
937
+ const requirement = evaluateEncryptionRequirement({
938
+ encryptRequested,
939
+ policy: encryptionPolicy ?? undefined,
940
+ mailboxLoaded: !!mailbox,
941
+ hasEncryptionKey,
942
+ recipients: currentStatuses,
943
+ });
944
+ const encryptionDecided = requirement.required;
945
+ const autosaveSuppressed = encryptionDecided;
946
+ const latestRef = useRef({
947
+ draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
948
+ statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
949
+ });
950
+ latestRef.current = {
951
+ draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
952
+ statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
953
+ };
954
+ /** A message that turned out to need encryption after a plaintext copy of it was saved: the saved copy is replaced by an empty draft (the same
955
+ * uid, so attachments and the folder are undisturbed), chained after any save still on the wire. Best effort - a failure is reported once. */
956
+ function scrubSavedDraft(target) {
957
+ lastSavedRef.current = null;
958
+ setSaveStatus("idle");
959
+ const previous = saveInFlightRef.current;
960
+ const scrub = (async () => {
961
+ await previous;
962
+ try {
963
+ // Its answer (the new version) is what a later delete waits for through `saveInFlightRef`, like any save's.
964
+ return await assembleDraft(target.uid, { to: [], cc: [], bcc: [], subject: "", html: "" });
965
+ }
966
+ catch {
967
+ notify({
968
+ id: `scrub-draft:${target.uid}`,
969
+ kind: "warning",
970
+ title: "An earlier draft copy is still saved",
971
+ message: "This message needs encryption, but the copy saved before that was known couldn't be replaced. Discarding the draft removes it.",
972
+ });
973
+ return undefined;
974
+ }
975
+ })();
976
+ saveInFlightRef.current = scrub;
977
+ }
978
+ // Only a message that was really saved (`lastSavedRef` names its draft) has a plaintext copy to replace.
979
+ useEffect(() => {
980
+ if (encryptionDecided && draft && lastSavedRef.current?.startsWith(`${draft.uid}:`)) {
981
+ scrubSavedDraft(draft);
982
+ }
983
+ }, [encryptionDecided, draft?.uid]);
1080
984
  // Recipients a reply could only work out after this window opened (Reply All: whoever the original's headers name)
1081
985
  // replace the ones it opened with - but only in a field the user hasn't touched, never over what they typed.
1082
986
  useEffect(() => {
@@ -1095,8 +999,6 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1095
999
  useEffect(() => {
1096
1000
  const due = !!draft &&
1097
1001
  contentReady &&
1098
- cryptoContextReady &&
1099
- !sending &&
1100
1002
  !autosaveSuppressed &&
1101
1003
  hasUserContent &&
1102
1004
  lastSavedRef.current !== `${draft.uid}:${contentKey}`;
@@ -1106,7 +1008,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1106
1008
  }
1107
1009
  const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
1108
1010
  return () => clearTimeout(timer);
1109
- }, [draft, contentReady, cryptoContextReady, sending, autosaveSuppressed, hasUserContent, contentKey]);
1011
+ }, [draft, contentReady, autosaveSuppressed, hasUserContent, contentKey]);
1110
1012
  // Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
1111
1013
  // right away rather than losing it.
1112
1014
  useEffect(() => () => {
@@ -1148,7 +1050,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1148
1050
  // out - that navigation must not be cancellable.
1149
1051
  useEffect(() => {
1150
1052
  function handleBeforeUnload(event) {
1151
- if (finishedRef.current || sendingRef.current || isSigningOut()) {
1053
+ if (finishedRef.current || isSigningOut()) {
1152
1054
  return;
1153
1055
  }
1154
1056
  const latest = latestRef.current;
@@ -1168,7 +1070,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1168
1070
  // Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
1169
1071
  // with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
1170
1072
  // window must not move the caret there again.
1171
- const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending;
1073
+ const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending || !!resume;
1172
1074
  const fieldsFocusedRef = useRef(false);
1173
1075
  const bodyFocusedRef = useRef(false);
1174
1076
  const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
@@ -1202,35 +1104,34 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1202
1104
  // Keyboard shortcuts, active while the focus is inside this window (`container`; the scope attribute on its root is what tells the
1203
1105
  // dispatcher the focus is in a compose window at all). They do what the Send and Close buttons and autosave do, under the same conditions -
1204
1106
  // a shortcut pressed while its button would be disabled is consumed and does nothing.
1205
- const canSend = !!draft && !sending && !closing && cryptoContextReady;
1107
+ // Send never waits for the encryption settings to load (fail open, see `encryptionRequirement.ts`); it only needs the draft to exist. Pressed
1108
+ // while an earlier press is still being handled it does nothing (`submit()` guards that itself).
1109
+ const canSend = !!draft && !closing && !waitingForUploads;
1206
1110
  useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
1207
1111
  useShortcut(SHORTCUTS.compose.saveDraft, () => {
1208
- // Autosave's own conditions: never a draft that must not be stored as plaintext, and nothing while a send is on the wire.
1209
- if (draft && contentReady && cryptoContextReady && !sending && !closing && !autosaveSuppressed && hasUserContent) {
1112
+ // Autosave's own conditions: never a draft that must not be stored as plaintext.
1113
+ if (draft && contentReady && !closing && !autosaveSuppressed && hasUserContent) {
1210
1114
  void saveDraftNow();
1211
1115
  }
1212
1116
  }, { container: windowRef });
1213
1117
  useShortcut(SHORTCUTS.compose.close, () => {
1214
- if (!sending && !closing) {
1118
+ if (!closing) {
1215
1119
  void handleClose();
1216
1120
  }
1217
1121
  }, { container: windowRef });
1218
1122
  useShortcut(SHORTCUTS.compose.create, () => openCompose({ mailboxUid }), { container: windowRef });
1219
1123
  const title = subject.trim() || "New Message";
1220
1124
  const titleId = `compose-title-${id}`;
1221
- const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [closePrompt?.retry && (_jsx(Button, { type: "button", onClick: retryCryptoContext, className: "!w-auto", children: "Retry" })), closePrompt?.closeAnyway && (_jsx(Button, { type: "button", onClick: () => {
1222
- setClosePrompt(null);
1223
- void handleClose(true);
1224
- }, disabled: sending || closing, className: "!w-auto", children: "Close, keep draft" })), _jsx(Button, { type: "button", onClick: () => void discardNow(), disabled: sending || closing, className: "!w-auto", children: "Discard" }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => setClosePrompt(null), className: "!w-auto", children: "Keep editing" })] })] }));
1125
+ const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", onClick: () => void discardNow(), disabled: closing, className: "!w-auto", children: "Discard" }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => setClosePrompt(null), className: "!w-auto", children: "Keep editing" })] })] }));
1225
1126
  if (minimized) {
1226
- return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), sendError && _jsx("span", { className: "shrink-0 text-xs font-bold uppercase tracking-wide", children: "Not sent" }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: sending || closing })] })] }) }), discardModal] }));
1127
+ return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: closing })] })] }) }), discardModal] }));
1227
1128
  }
1228
1129
  return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "data-shortcut-scope": "compose", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
1229
1130
  "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1230
1131
  isMobile
1231
1132
  ? "fixed inset-0 w-full h-full rounded-none border-0"
1232
1133
  : ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1233
- ].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: sending || closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(SendFailureAlert, { message: sendError, lines: sendDetails }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), cryptoCheckUnavailable && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: CRYPTO_UNAVAILABLE_MESSAGE }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: retryCryptoContext, children: "Retry" })] }) })), encryptionBlocked && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsxs("p", { className: "mb-2", children: ["This message can\u2019t be encrypted for everyone: ", encryptionBlocked.map((r) => r.address).join(", "), encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file", ". Remove ", encryptionBlocked.length > 1 ? "these recipients" : "this recipient", " from To/Cc/Bcc, or send the whole message in plaintext."] }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: "Send without encryption" })] }) })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`, value: mailboxUid ?? "", disabled: hasUploads || sending, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
1134
+ ].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`, value: mailboxUid ?? "", disabled: hasUploads, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
1234
1135
  const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
1235
1136
  .map((r) => recipientStatuses[r.address])
1236
1137
  .filter((status) => !!status);
@@ -1238,5 +1139,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1238
1139
  return null;
1239
1140
  }
1240
1141
  return (_jsx("ul", { className: "flex flex-wrap gap-1.5 px-3 pt-1.5", "aria-label": "Recipient encryption availability", children: knownRecipients.map((status) => (_jsxs("li", { className: `text-xs font-medium py-0.5 px-2 rounded-pill ${status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"}`, children: [status.address, " ", status.canEncrypt ? "supports encryption" : "no encryption key found"] }, status.address))) }));
1241
- })(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !draft || sending || closing || !cryptoContextReady, 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", children: sending ? "Sending…" : "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || sending || closing || !cryptoContextReady, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: sending || closing, 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", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
1142
+ })(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !draft || closing || waitingForUploads, 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", children: "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || closing || waitingForUploads, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: closing, 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", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
1242
1143
  }