@rapidmx/web-client 0.10.0 → 0.12.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 (324) 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/mailboxes/new/index.tsx +3 -3
  7. package/apps/admin/quarantine/index.tsx +2 -1
  8. package/apps/admin/signing-certificates/index.tsx +343 -0
  9. package/apps/escrow/_layout.tsx +8 -2
  10. package/apps/shared/appearance/AppearanceForm.tsx +392 -0
  11. package/apps/shared/appearance/AppearanceHead.tsx +49 -0
  12. package/apps/shared/appearance/AppearanceProvider.tsx +470 -0
  13. package/apps/shared/appearance/ColorField.tsx +107 -0
  14. package/apps/shared/appearance/appearanceCache.ts +106 -0
  15. package/apps/shared/appearance/appearanceContext.ts +77 -0
  16. package/apps/shared/appearance/bootScript.ts +35 -0
  17. package/apps/shared/appearance/color.ts +169 -0
  18. package/apps/shared/appearance/photo.ts +88 -0
  19. package/apps/shared/appearance/resolvedTheme.ts +67 -0
  20. package/apps/shared/appearance/theme.ts +355 -0
  21. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +4 -4
  22. package/apps/shared/components/admin/layout/AdminShell.tsx +22 -10
  23. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +107 -60
  24. package/apps/shared/components/admin/settings/BrandingForm.tsx +24 -0
  25. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +60 -14
  26. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +65 -16
  27. package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
  28. package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
  29. package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
  30. package/apps/shared/components/contacts/ContactForm.tsx +11 -5
  31. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
  32. package/apps/shared/components/contacts/ContactsToolbar.tsx +25 -52
  33. package/apps/shared/components/contacts/layout/ContactsShell.tsx +10 -5
  34. package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
  35. package/apps/shared/components/layout/AppShell.tsx +98 -32
  36. package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
  37. package/apps/shared/components/layout/RailIcon.tsx +100 -0
  38. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
  39. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
  40. package/apps/shared/components/layout/UserMenu.tsx +75 -6
  41. package/apps/shared/components/mail/ConversationList.tsx +4 -2
  42. package/apps/shared/components/mail/ConversationThreadPane.tsx +487 -475
  43. package/apps/shared/components/mail/LazyReadingPane.tsx +14 -16
  44. package/apps/shared/components/mail/MailSelectionBar.tsx +2 -10
  45. package/apps/shared/components/mail/MessageDetailPane.tsx +366 -287
  46. package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
  47. package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
  48. package/apps/shared/components/mail/compose/ComposeContext.tsx +35 -2
  49. package/apps/shared/components/mail/compose/ComposeWindow.tsx +239 -437
  50. package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
  51. package/apps/shared/components/mail/layout/MailShell.tsx +39 -25
  52. package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
  53. package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
  54. package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
  55. package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
  56. package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
  57. package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
  58. package/apps/shared/components/mail/reading/color.ts +245 -0
  59. package/apps/shared/components/mail/reading/frameControl.ts +142 -0
  60. package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
  61. package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
  62. package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
  63. package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
  64. package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
  65. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
  66. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  67. package/apps/shared/components/sharing/PrincipalListField.tsx +82 -0
  68. package/apps/shared/components/sharing/PrincipalPicker.tsx +76 -0
  69. package/apps/shared/components/sharing/PrincipalResolver.tsx +164 -0
  70. package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
  71. package/apps/shared/components/tasks/layout/TasksShell.tsx +9 -5
  72. package/apps/shared/mail/folderCounts.ts +9 -3
  73. package/apps/shared/mail/folderTree.ts +143 -0
  74. package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
  75. package/apps/shared/mail/outbox/composeBridge.ts +69 -0
  76. package/apps/shared/mail/outbox/outboxState.ts +88 -0
  77. package/apps/shared/mail/outbox/pendingSends.ts +146 -0
  78. package/apps/shared/mail/outbox/sendDecision.ts +157 -0
  79. package/apps/shared/mail/outbox/sendJob.ts +427 -0
  80. package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
  81. package/apps/shared/mail/outbox/sendState.ts +35 -0
  82. package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
  83. package/apps/shared/mail/useMailConnection.ts +81 -28
  84. package/apps/shared/mail/useMailLiveUpdates.ts +55 -10
  85. package/apps/shared/mail/useNewMailNotifications.ts +47 -32
  86. package/apps/shared/navigation/AppRouter.tsx +2 -2
  87. package/apps/shared/notifications/NotificationCenter.tsx +215 -0
  88. package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
  89. package/apps/shared/notifications/apiErrors.ts +98 -0
  90. package/apps/shared/notifications/headerOffset.ts +35 -0
  91. package/apps/shared/notifications/pushStatus.ts +56 -0
  92. package/apps/shared/notifications/store.ts +549 -0
  93. package/apps/shared/notifications/systemErrors.ts +63 -0
  94. package/apps/shared/notifications/useNotifications.ts +41 -0
  95. package/apps/shared/signing/enrollmentStorage.ts +33 -0
  96. package/apps/shared/signing/enrollmentTracker.ts +385 -0
  97. package/apps/shared/signing/enrollmentView.ts +251 -0
  98. package/apps/shared/signing/signingInfo.ts +58 -0
  99. package/apps/shared/signing/useNow.ts +19 -0
  100. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
  101. package/apps/shared/styles/app.css +80 -0
  102. package/apps/www/_layout.tsx +8 -2
  103. package/apps/www/_routes.ts +1 -0
  104. package/apps/www/calendar/index.tsx +9 -14
  105. package/apps/www/contacts/[uid].tsx +5 -8
  106. package/apps/www/contacts/index.tsx +587 -584
  107. package/apps/www/index.tsx +38 -27
  108. package/apps/www/messages/[uid].tsx +3 -1
  109. package/apps/www/settings/appearance/index.tsx +21 -0
  110. package/apps/www/settings/auto-reply/index.tsx +3 -7
  111. package/apps/www/settings/encryption/index.tsx +151 -116
  112. package/apps/www/settings/filters/[uid].tsx +3 -1
  113. package/apps/www/settings/filters/new/index.tsx +3 -1
  114. package/apps/www/settings/privacy/index.tsx +3 -8
  115. package/apps/www/settings/read-receipts/index.tsx +3 -7
  116. package/apps/www/settings/sharing/index.tsx +44 -65
  117. package/apps/www/settings/signatures/[uid].tsx +3 -1
  118. package/apps/www/settings/signatures/new/index.tsx +3 -2
  119. package/apps/www/tasks/index.tsx +11 -16
  120. package/dist/apps/admin/_layout.d.ts +5 -1
  121. package/dist/apps/admin/_layout.js +3 -2
  122. package/dist/apps/admin/index.js +3 -2
  123. package/dist/apps/admin/ingest-queue/index.js +2 -1
  124. package/dist/apps/admin/mailboxes/[uid].js +9 -6
  125. package/dist/apps/admin/mailboxes/new/index.js +1 -1
  126. package/dist/apps/admin/quarantine/index.js +2 -1
  127. package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
  128. package/dist/apps/admin/signing-certificates/index.js +161 -0
  129. package/dist/apps/escrow/_layout.d.ts +5 -1
  130. package/dist/apps/escrow/_layout.js +3 -2
  131. package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
  132. package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
  133. package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
  134. package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
  135. package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
  136. package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
  137. package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
  138. package/dist/apps/shared/appearance/ColorField.js +40 -0
  139. package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
  140. package/dist/apps/shared/appearance/appearanceCache.js +80 -0
  141. package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
  142. package/dist/apps/shared/appearance/appearanceContext.js +29 -0
  143. package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
  144. package/dist/apps/shared/appearance/bootScript.js +33 -0
  145. package/dist/apps/shared/appearance/color.d.ts +65 -0
  146. package/dist/apps/shared/appearance/color.js +132 -0
  147. package/dist/apps/shared/appearance/photo.d.ts +17 -0
  148. package/dist/apps/shared/appearance/photo.js +79 -0
  149. package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
  150. package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
  151. package/dist/apps/shared/appearance/theme.d.ts +111 -0
  152. package/dist/apps/shared/appearance/theme.js +251 -0
  153. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +3 -2
  154. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  155. package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
  156. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
  157. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
  158. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
  159. package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
  160. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +25 -5
  161. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +18 -7
  162. package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
  163. package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
  164. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
  165. package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
  166. package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
  167. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  168. package/dist/apps/shared/components/contacts/ContactsToolbar.js +24 -9
  169. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -1
  170. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
  171. package/dist/apps/shared/components/layout/AppShell.d.ts +6 -1
  172. package/dist/apps/shared/components/layout/AppShell.js +59 -13
  173. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
  174. package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
  175. package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
  176. package/dist/apps/shared/components/layout/RailIcon.js +85 -0
  177. package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
  178. package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
  179. package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
  180. package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
  181. package/dist/apps/shared/components/layout/UserMenu.d.ts +7 -1
  182. package/dist/apps/shared/components/layout/UserMenu.js +41 -7
  183. package/dist/apps/shared/components/mail/ConversationList.js +3 -2
  184. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +2 -1
  185. package/dist/apps/shared/components/mail/ConversationThreadPane.js +42 -20
  186. package/dist/apps/shared/components/mail/LazyReadingPane.js +9 -8
  187. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +3 -4
  188. package/dist/apps/shared/components/mail/MailSelectionBar.js +2 -3
  189. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +18 -7
  190. package/dist/apps/shared/components/mail/MessageDetailPane.js +86 -41
  191. package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
  192. package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
  193. package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
  194. package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
  195. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +8 -0
  196. package/dist/apps/shared/components/mail/compose/ComposeContext.js +28 -3
  197. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +2 -1
  198. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +229 -328
  199. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
  200. package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
  201. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +2 -0
  202. package/dist/apps/shared/components/mail/layout/MailShell.js +20 -14
  203. package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
  204. package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
  205. package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
  206. package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
  207. package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
  208. package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
  209. package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
  210. package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
  211. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
  212. package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
  213. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
  214. package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
  215. package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
  216. package/dist/apps/shared/components/mail/reading/color.js +201 -0
  217. package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
  218. package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
  219. package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
  220. package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
  221. package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
  222. package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
  223. package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
  224. package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
  225. package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
  226. package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
  227. package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
  228. package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
  229. package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
  230. package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
  231. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  232. package/dist/apps/shared/components/sharing/PrincipalListField.d.ts +24 -0
  233. package/dist/apps/shared/components/sharing/PrincipalListField.js +23 -0
  234. package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +28 -0
  235. package/dist/apps/shared/components/sharing/PrincipalPicker.js +24 -0
  236. package/dist/apps/shared/components/sharing/PrincipalResolver.d.ts +44 -0
  237. package/dist/apps/shared/components/sharing/PrincipalResolver.js +92 -0
  238. package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
  239. package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -1
  240. package/dist/apps/shared/mail/folderCounts.d.ts +1 -1
  241. package/dist/apps/shared/mail/folderCounts.js +9 -3
  242. package/dist/apps/shared/mail/folderTree.d.ts +55 -0
  243. package/dist/apps/shared/mail/folderTree.js +116 -0
  244. package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
  245. package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
  246. package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
  247. package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
  248. package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
  249. package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
  250. package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
  251. package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
  252. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
  253. package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
  254. package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
  255. package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
  256. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
  257. package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
  258. package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
  259. package/dist/apps/shared/mail/outbox/sendState.js +8 -0
  260. package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
  261. package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
  262. package/dist/apps/shared/mail/useMailConnection.d.ts +9 -8
  263. package/dist/apps/shared/mail/useMailConnection.js +64 -22
  264. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +17 -4
  265. package/dist/apps/shared/mail/useMailLiveUpdates.js +36 -10
  266. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +4 -10
  267. package/dist/apps/shared/mail/useNewMailNotifications.js +43 -23
  268. package/dist/apps/shared/navigation/AppRouter.js +2 -2
  269. package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
  270. package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
  271. package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
  272. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
  273. package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
  274. package/dist/apps/shared/notifications/apiErrors.js +86 -0
  275. package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
  276. package/dist/apps/shared/notifications/headerOffset.js +33 -0
  277. package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
  278. package/dist/apps/shared/notifications/pushStatus.js +54 -0
  279. package/dist/apps/shared/notifications/store.d.ts +136 -0
  280. package/dist/apps/shared/notifications/store.js +416 -0
  281. package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
  282. package/dist/apps/shared/notifications/systemErrors.js +58 -0
  283. package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
  284. package/dist/apps/shared/notifications/useNotifications.js +18 -0
  285. package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
  286. package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
  287. package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
  288. package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
  289. package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
  290. package/dist/apps/shared/signing/enrollmentView.js +214 -0
  291. package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
  292. package/dist/apps/shared/signing/signingInfo.js +48 -0
  293. package/dist/apps/shared/signing/useNow.d.ts +2 -0
  294. package/dist/apps/shared/signing/useNow.js +18 -0
  295. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
  296. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
  297. package/dist/apps/shared/styles/app.css +80 -0
  298. package/dist/apps/www/_layout.d.ts +5 -1
  299. package/dist/apps/www/_layout.js +3 -2
  300. package/dist/apps/www/_routes.js +1 -0
  301. package/dist/apps/www/calendar/index.js +3 -4
  302. package/dist/apps/www/contacts/[uid].js +5 -7
  303. package/dist/apps/www/contacts/index.js +14 -21
  304. package/dist/apps/www/index.js +37 -19
  305. package/dist/apps/www/messages/[uid].js +3 -1
  306. package/dist/apps/www/settings/appearance/index.d.ts +4 -0
  307. package/dist/apps/www/settings/appearance/index.js +13 -0
  308. package/dist/apps/www/settings/auto-reply/index.js +3 -6
  309. package/dist/apps/www/settings/encryption/index.js +103 -95
  310. package/dist/apps/www/settings/filters/[uid].js +3 -1
  311. package/dist/apps/www/settings/filters/new/index.js +3 -1
  312. package/dist/apps/www/settings/privacy/index.js +5 -8
  313. package/dist/apps/www/settings/read-receipts/index.js +3 -6
  314. package/dist/apps/www/settings/sharing/index.js +19 -33
  315. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  316. package/dist/apps/www/settings/signatures/new/index.js +3 -2
  317. package/dist/apps/www/tasks/index.js +8 -14
  318. package/package.json +2 -2
  319. package/apps/shared/components/mail/NewMailToasts.tsx +0 -141
  320. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
  321. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +0 -19
  322. package/dist/apps/shared/components/mail/NewMailToasts.js +0 -55
  323. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
  324. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
@@ -20,7 +20,6 @@ import {
20
20
  Mailbox,
21
21
  Message,
22
22
  assembleDraft,
23
- assembleDraftRaw,
24
23
  attachmentContentUrl,
25
24
  createDraft,
26
25
  deleteMessage,
@@ -28,21 +27,20 @@ import {
28
27
  getMessage,
29
28
  listFolders,
30
29
  listMailboxes,
31
- sendMessage,
32
- setMessageRequestReceipt,
33
30
  uploadAttachment,
34
31
  } from "@rapidmx/react-shared/mail/mailApi.js";
35
32
  import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
36
- import { describeSendFailure } from "@rapidmx/react-shared/mail/sendFailure.js";
37
33
  import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
38
34
  import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
39
- import { decideMessageEncryption, resolveRecipientEncryption, RecipientEncryptionStatus } from "@rapidmx/react-shared/crypto/composeSecurity.js";
35
+ import { resolveRecipientEncryption, RecipientEncryptionStatus } from "@rapidmx/react-shared/crypto/composeSecurity.js";
40
36
  import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
41
37
  import { EncryptionPolicy, findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
42
38
  import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
43
- import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
44
- import type { ProtectedHeaders } from "@rapidmx/react-shared/crypto/smimeMessage.js";
45
39
  import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
40
+ import { notify } from "../../../notifications/store.js";
41
+ import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
42
+ import { decideSend, SendBlock } from "../../../mail/outbox/sendDecision.js";
43
+ import { SendRequest, sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
46
44
  import { useCompose, type ComposeSession } from "./ComposeContext.js";
47
45
  import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
48
46
  import { SHORTCUTS, ShortcutDef } from "../../../keyboard/keymap.js";
@@ -54,7 +52,6 @@ import RecipientInput from "./RecipientInput.js";
54
52
  import { parseRecipientList } from "./recipients.js";
55
53
  import RichTextEditor from "./RichTextEditor.js";
56
54
  import ScheduleSendPicker from "./ScheduleSendPicker.js";
57
- import SendFailureAlert from "./SendFailureAlert.js";
58
55
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
59
56
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
60
57
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
@@ -79,71 +76,29 @@ export interface ComposeWindowProps {
79
76
  /** Debounce between the last edit and the draft autosave. */
80
77
  export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
81
78
 
82
- /** Automatic retries of a failed mailbox/encryption-policy load; after the last one only "Retry" tries again. */
83
- export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000];
84
-
85
- /** Signed/encrypted bodies are built client-side from the editor's HTML verbatim, so an inline image
86
- * (an uploaded attachment previewed via its content URL, or a `cid:` reference) would never be carried -
87
- * only `assembleDraft()`'s server-side plaintext path rewrites those into real MIME parts. */
88
- const INLINE_IMAGE_PATTERN = /<img\b[^>]*\bsrc\s*=\s*["']?(?:cid:|[^"'\s>]*\/mail\/attachments\/[^"'\s>]+\/content)/i;
89
-
90
- const KEYS_LOCKED_SIGN_MESSAGE =
91
- "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.";
92
- const KEYS_LOCKED_ENCRYPT_MESSAGE =
93
- "This message can't be encrypted right now - your encryption key is locked. Unlock and send again, or send it without encryption.";
94
- const POLICY_UNAVAILABLE_MESSAGE =
95
- "Your encryption settings couldn't be loaded, so this message can't be encrypted right now. Try again, or send it without encryption.";
96
- const BCC_ENCRYPTED_MESSAGE = "Bcc recipients can't be used with encrypted messages. Remove Bcc recipients or turn off encryption.";
97
- const LOOKUP_UNAVAILABLE_MESSAGE =
98
- "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.";
79
+ /** Automatic, silent retries of a failed mailbox/encryption-policy (or recipient key) load, one entry per retry - a flapping gateway is waited out
80
+ * in the background, without a word to the user. */
81
+ export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000, 60000];
82
+
99
83
  /** Thrown by `deleteDraft()` when the server copy is no longer this window's draft (sent or scheduled elsewhere). */
100
84
  const NO_LONGER_A_DRAFT_MESSAGE = "This message was already sent or scheduled from another window, so it wasn't deleted.";
101
85
 
102
- /** The server refuses signed/encrypted mail whose From display name differs from its own safe form, which drops a
103
- * name containing `@` (or a look-alike) or a line break. */
104
- const UNSAFE_DISPLAY_NAME_PATTERN = /[@@﹫\r\n]/;
105
-
106
86
  /** Whether `fresh` (a re-read of a draft) is still an unsent draft in `folderUid`: never delete a message another
107
87
  * window has since sent, scheduled, or started sending. */
108
88
  function isStillDraft(fresh: Message, folderUid: string): boolean {
109
89
  return fresh.folderUid === folderUid && !fresh.scheduledSendTime && !fresh.scheduledSendLeaseExpiresAt && !fresh.scheduledSendRelayedAt;
110
90
  }
111
91
 
112
- /** Any tier of `policy` auto-encrypts - only then can a message with recipients still to be added turn out encrypted
113
- * without the user asking. */
114
- function policyCanAutoEncrypt(policy: EncryptionPolicy): boolean {
115
- return [policy.encryptSameOrg, policy.encryptFederated, policy.encryptExternal].includes("automatic");
116
- }
117
-
118
92
  const SAVE_STATUS_LABEL = { idle: "", saving: "Saving…", saved: "Draft saved", error: "Couldn't save draft" } as const;
119
93
 
120
94
  const DISCARD_TITLE = "Discard this draft?";
95
+ /** Shown only for a message that is actually headed for encryption (asked for, or applied by the loaded policy) - see `encryptionRequirement.ts`. */
121
96
  const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so closing this window discards what you've written.";
122
- const CHECKING_CLOSE_MESSAGE =
123
- "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.";
124
- const CRYPTO_UNAVAILABLE_MESSAGE =
125
- "Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
126
- const NO_RECIPIENTS_CLOSE_MESSAGE =
127
- "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.";
128
-
129
- const SEND_FAILED_TITLE = "This message wasn't sent";
130
- function sendFailedCloseMessage(reason: string): string {
131
- return `Sending it failed (${reason.replace(/\.$/, "")}). Closing keeps it as a draft, unsent. Keep editing to try again, or discard it.`;
132
- }
133
97
 
134
98
  /** The confirmation shown before a Close/Discard throws content away (or when a Close couldn't save it). */
135
99
  interface ClosePrompt {
136
100
  title: string;
137
101
  message: string;
138
- /** Also offer "Retry" for loading the encryption settings. */
139
- retry?: boolean;
140
- /** Also offer "Close, keep draft": a failed send's close prompt, where closing keeps the unsent draft as it is. */
141
- closeAnyway?: boolean;
142
- }
143
-
144
- interface SecurityBlock {
145
- message: string;
146
- overrideLabel: string;
147
102
  }
148
103
 
149
104
  const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
@@ -224,7 +179,7 @@ export default function ComposeWindow({
224
179
  autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS,
225
180
  cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS,
226
181
  }: ComposeWindowProps) {
227
- const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late } = session;
182
+ const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late, resume } = session;
228
183
  // What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
229
184
  // only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
230
185
  const baselineTo = late?.to ?? initialTo;
@@ -241,7 +196,7 @@ export default function ComposeWindow({
241
196
  const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
242
197
  // Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
243
198
  // Drafts folder - so once anything has been uploaded the sender can no longer be switched.
244
- const [hasUploads, setHasUploads] = useState(false);
199
+ const [hasUploads, setHasUploads] = useState(!!resume && resume.attachments.length > 0);
245
200
  const isMobile = useIsMobile();
246
201
  const { requestUnlock } = useUnlockPrompt();
247
202
  const { openCompose } = useCompose();
@@ -253,26 +208,29 @@ export default function ComposeWindow({
253
208
  const windowRef = useRef<HTMLDivElement>(null);
254
209
  const [draftsFolderUid, setDraftsFolderUid] = useState<string | undefined>();
255
210
  const [folderError, setFolderError] = useState<string | null>(null);
256
- const [draft, setDraft] = useState<Message | null>(null);
211
+ // A window re-opened around a message that was already composed (a failed send's "Open draft") continues that server draft and starts with
212
+ // every field as it was typed; its body is not seeded from a signature or a quote.
213
+ const [draft, setDraft] = useState<Message | null>(resume?.draft ?? null);
257
214
  // Drafts replaced by a From switch, awaiting deletion until their replacement has actually been created.
258
215
  const supersededDraftsRef = useRef<Message[]>([]);
259
216
  const [draftError, setDraftError] = useState<string | null>(null);
260
217
  const [expanded, setExpanded] = useState(false);
261
218
  const [manualSize, setManualSize] = useState<Size | null>(null);
262
- const [to, setTo] = useState(initialTo ?? "");
263
- const [cc, setCc] = useState(initialCc ?? "");
264
- const [bcc, setBcc] = useState("");
265
- const [showCcBcc, setShowCcBcc] = useState(!!initialCc);
266
- const [subject, setSubject] = useState(initialSubject ?? "");
267
- const [html, setHtml] = useState("");
268
- const [contentReady, setContentReady] = useState(false);
269
- const [attachments, setAttachments] = useState<Attachment[]>([]);
219
+ const [to, setTo] = useState(resume?.to ?? initialTo ?? "");
220
+ const [cc, setCc] = useState(resume?.cc ?? initialCc ?? "");
221
+ const [bcc, setBcc] = useState(resume?.bcc ?? "");
222
+ const [showCcBcc, setShowCcBcc] = useState(!!(resume ? resume.cc || resume.bcc : initialCc));
223
+ const [subject, setSubject] = useState(resume?.subject ?? initialSubject ?? "");
224
+ const [html, setHtml] = useState(resume?.html ?? "");
225
+ const [contentReady, setContentReady] = useState(!!resume);
226
+ const [attachments, setAttachments] = useState<Attachment[]>(resume?.attachments ?? []);
270
227
  const [attachError, setAttachError] = useState<string | null>(null);
228
+ // A problem with the message as composed (no recipient, an attachment that can't be signed): shown here, and the window stays open. What
229
+ // goes wrong after Send was accepted is a pop-up instead (see `sendJob.ts`).
271
230
  const [sendError, setSendError] = useState<string | null>(null);
272
- // The technical facts behind `sendError` (per-recipient SMTP results, a transport error), when the server gave any.
273
- const [sendDetails, setSendDetails] = useState<string[]>([]);
274
- const [sending, setSending] = useState(false);
275
- const [requestReceipt, setRequestReceipt] = useState(false);
231
+ // Send was pressed while an attachment was still uploading: the window waits (and says so) rather than send without it.
232
+ const [waitingForUploads, setWaitingForUploads] = useState(false);
233
+ const [requestReceipt, setRequestReceipt] = useState(!!resume?.requestReceipt);
276
234
  const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
277
235
  const scheduleButtonRef = useRef<HTMLButtonElement>(null);
278
236
  const [mailbox, setMailbox] = useState<Mailbox | null>(null);
@@ -281,11 +239,10 @@ export default function ComposeWindow({
281
239
  // Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
282
240
  // `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
283
241
  // still turn it back on; this only changes the default.
284
- const [signEnabled, setSignEnabled] = useState(!suppressSigning);
242
+ const [signEnabled, setSignEnabled] = useState(resume ? resume.signEnabled : !suppressSigning);
285
243
  // A reply to or forward of an encrypted message starts (and, after a From switch, restarts) with encryption
286
244
  // requested - see `OpenComposeInput.encrypt`.
287
- const [encryptRequested, setEncryptRequested] = useState(!!initialEncrypt);
288
- const [encryptionBlocked, setEncryptionBlocked] = useState<RecipientEncryptionStatus[] | null>(null);
245
+ const [encryptRequested, setEncryptRequested] = useState(resume ? resume.encryptRequested : !!initialEncrypt);
289
246
  // Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
290
247
  // recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
291
248
  // just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
@@ -295,17 +252,15 @@ export default function ComposeWindow({
295
252
  // look one up twice), and a counter bumped by a From switch so a lookup for the previous sender is dropped.
296
253
  const lookupsInFlightRef = useRef(new Set<string>());
297
254
  const discoveryGenerationRef = useRef(0);
298
- // A failed lookup stores no status (it would read as "no key" and let a message that must be encrypted autosave
299
- // or send as plaintext): it's retried with the same backoff as the mailbox/policy load, and once those retries
300
- // run out the address is "exhausted" - shown as a check that couldn't be done, with Retry.
255
+ // A failed lookup stores no status: the recipient is simply "not known to be encryptable" (fail open - see `encryptionRequirement.ts`), and
256
+ // the lookup is retried in the background with the same backoff as the mailbox/policy load, without a word to the user.
301
257
  const lookupFailuresRef = useRef(new Map<string, number>());
302
258
  const lookupRetryTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
303
259
  const [lookupRetryToken, setLookupRetryToken] = useState(0);
304
- const [exhaustedLookups, setExhaustedLookups] = useState<Record<string, true>>({});
305
- // A send refused because signing/encryption was wanted but can't happen right now (keys locked, the
306
- // encryption policy/mailbox couldn't be loaded, or Bcc recipients on an encrypted message) - never a
307
- // silent downgrade to plaintext; the user has to explicitly pick the override.
308
- const [securityBlock, setSecurityBlock] = useState<SecurityBlock | null>(null);
260
+ // A send refused *before it left* because signing/encryption was asked for (or the loaded policy applies it) but can't happen right now
261
+ // (keys locked, Bcc recipients on an encrypted message, a recipient without a key) - never a silent downgrade to plaintext; the user
262
+ // has to explicitly pick the override.
263
+ const [securityBlock, setSecurityBlock] = useState<SendBlock | null>(null);
309
264
  // The schedule time of the send that got blocked (undefined for an immediate send), so the banners'
310
265
  // override buttons replay the same kind of send the user originally asked for.
311
266
  const [blockedScheduleIso, setBlockedScheduleIso] = useState<string | undefined>();
@@ -326,8 +281,11 @@ export default function ComposeWindow({
326
281
  const lastSavedRef = useRef<string | null>(null);
327
282
  /** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
328
283
  const pendingSaveRef = useRef(false);
329
- /** Mirrors `sending` for the page-leave/sign-out flushes, which run outside React's render cycle. */
330
- const sendingRef = useRef(false);
284
+ /** Send was pressed and is being validated/handed over: a second press (a double click, the shortcut held or repeated) does nothing. */
285
+ const submittingRef = useRef(false);
286
+ /** Attachment/inline-image uploads on the wire, and who is waiting for them to finish (Send pressed meanwhile). */
287
+ const uploadsInFlightRef = useRef(0);
288
+ const uploadWaitersRef = useRef<(() => void)[]>([]);
331
289
  /** The latest save request, if any - each save is chained after the one before it. Always settles (never
332
290
  * rejects), to the saved message, or `undefined` when that save failed. */
333
291
  const saveInFlightRef = useRef<Promise<Message | undefined>>(Promise.resolve(undefined));
@@ -336,13 +294,10 @@ export default function ComposeWindow({
336
294
 
337
295
  // A mailbox with no keys enrolled yet just means sign/encrypt stay unavailable for this compose session -
338
296
  // encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
339
- // `cryptoContextReady` (below) gates Send/Send-later until both calls have settled either way, so a
340
- // send that happens to race this fetch can't silently skip encryption the spec says should apply.
341
- // A failed load is retried with backoff (`cryptoRetryDelaysMs`), then on demand ("Retry"): until both have
342
- // loaded, whether this message must be encrypted is unknown, so it isn't autosaved (see `autosaveSuppressed`).
343
- const [cryptoContextReady, setCryptoContextReady] = useState(false);
344
- const [cryptoLoadFailed, setCryptoLoadFailed] = useState(false);
345
- const [cryptoRetryToken, setCryptoRetryToken] = useState(0);
297
+ // The mailbox and the encryption policy load in the background and *nothing waits for them*: until both have loaded, the message is treated as
298
+ // not encrypted - its draft saves, Close and Send work, and no message about it is shown (see `encryptionRequirement.ts`). A failed load is
299
+ // retried silently with backoff (`cryptoRetryDelaysMs`); if it later turns out the message must be encrypted, `encryptionDecided` (below) starts
300
+ // to apply - see the transition rules there.
346
301
 
347
302
  useEffect(() => {
348
303
  let cancelled = false;
@@ -379,8 +334,6 @@ export default function ComposeWindow({
379
334
  let mailboxLoaded = mailbox?.uid === mailboxUid;
380
335
  let policyLoaded = !!encryptionPolicy;
381
336
  let retries = 0;
382
- setCryptoContextReady(false);
383
- setCryptoLoadFailed(false);
384
337
  async function load() {
385
338
  await Promise.all([
386
339
  mailboxLoaded ||
@@ -404,13 +357,7 @@ export default function ComposeWindow({
404
357
  () => undefined,
405
358
  ),
406
359
  ]);
407
- if (cancelled) {
408
- return;
409
- }
410
- const failed = !mailboxLoaded || !policyLoaded;
411
- setCryptoContextReady(true);
412
- setCryptoLoadFailed(failed);
413
- if (failed && retries < cryptoRetryDelaysMs.length) {
360
+ if (!cancelled && (!mailboxLoaded || !policyLoaded) && retries < cryptoRetryDelaysMs.length) {
414
361
  timer = setTimeout(() => void load(), cryptoRetryDelaysMs[retries++]);
415
362
  }
416
363
  }
@@ -419,7 +366,7 @@ export default function ComposeWindow({
419
366
  cancelled = true;
420
367
  clearTimeout(timer);
421
368
  };
422
- }, [mailboxUid, cryptoRetryToken]);
369
+ }, [mailboxUid]);
423
370
 
424
371
  useEffect(() => {
425
372
  if (!mailboxUid) {
@@ -448,14 +395,14 @@ export default function ComposeWindow({
448
395
  * Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
449
396
  * caching the result so the small per-recipient indicator below the recipient fields can update
450
397
  * without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
451
- * and not the same lookup `assembleForSend()` performs again at send time (deliberately: this is a
398
+ * and not the lookup the background send (`sendJob.ts`) performs again at send time (deliberately: this is a
452
399
  * best-effort UI hint, not something a stale value here should be trusted to skip at send time).
453
400
  * A mailbox/policy fetch that hasn't resolved yet just leaves that address with no indicator - the effect
454
401
  * below runs it again for every current recipient once both have loaded. A failed lookup leaves it without
455
- * a status too, and is retried after `cryptoRetryDelaysMs` (then only on blur, Close or Retry).
402
+ * a status too, and is retried silently after `cryptoRetryDelaysMs` (then only on blur).
456
403
  *
457
- * The statuses also drive autosave: a recipient without one yet (lookup pending, failed or never run) or a
458
- * decision that would auto-encrypt keeps the plaintext draft save off (see `autosaveSuppressed`).
404
+ * The statuses also drive `encryptionRequirement.ts`: only when every recipient has one, and the loaded policy
405
+ * auto-encrypts for all of them, does the message turn into one that is never saved as a plaintext draft.
459
406
  */
460
407
  function checkRecipientDiscovery(addresses: ComposeRecipientInput[]) {
461
408
  if (!mailbox || !encryptionPolicy) {
@@ -488,12 +435,11 @@ export default function ComposeWindow({
488
435
  }
489
436
  }
490
437
 
491
- /** Schedules the next automatic retry of a failed lookup, or marks the address exhausted once they've run out. */
438
+ /** Schedules the next automatic, silent retry of a failed lookup (until the backoff has run out; a later blur tries again). */
492
439
  function noteLookupFailed(address: string) {
493
440
  const failures = (lookupFailuresRef.current.get(address) ?? 0) + 1;
494
441
  lookupFailuresRef.current.set(address, failures);
495
442
  if (failures > cryptoRetryDelaysMs.length) {
496
- setExhaustedLookups((prev) => ({ ...prev, [address]: true }));
497
443
  return;
498
444
  }
499
445
  const timer = setTimeout(() => {
@@ -518,7 +464,7 @@ export default function ComposeWindow({
518
464
  checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
519
465
  }, [mailbox, encryptionPolicy, lookupRetryToken]);
520
466
 
521
- // Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
467
+ // 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)
522
468
  // plus any quoted original message (laid out by `buildComposeBodyHtml()`: an empty first paragraph for the
523
469
  // caret, then the signature, then the quote), before `RichTextEditor` ever mounts (gated by `contentReady`
524
470
  // below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
@@ -676,9 +622,7 @@ export default function ComposeWindow({
676
622
  discoveryGenerationRef.current += 1;
677
623
  lookupsInFlightRef.current = new Set();
678
624
  lookupFailuresRef.current = new Map();
679
- setExhaustedLookups({});
680
625
  setEncryptRequested(!!initialEncrypt);
681
- setEncryptionBlocked(null);
682
626
  setSecurityBlock(null);
683
627
  offeredCryptoRef.current = { sign: false, encrypt: false };
684
628
  setFromMailboxUid(nextMailboxUid);
@@ -738,6 +682,7 @@ export default function ComposeWindow({
738
682
  setAttachError("Please wait for the draft to finish loading before inserting an image.");
739
683
  return null;
740
684
  }
685
+ uploadStarted();
741
686
  try {
742
687
  const attachment = await uploadAttachment(draft.uid, file);
743
688
  setHasUploads(true);
@@ -746,6 +691,21 @@ export default function ComposeWindow({
746
691
  } catch (err) {
747
692
  setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
748
693
  return null;
694
+ } finally {
695
+ uploadFinished();
696
+ }
697
+ }
698
+
699
+ /** Send waits for every upload started before it: see `submit()`. */
700
+ function uploadStarted() {
701
+ uploadsInFlightRef.current += 1;
702
+ }
703
+ function uploadFinished() {
704
+ uploadsInFlightRef.current -= 1;
705
+ if (uploadsInFlightRef.current === 0) {
706
+ for (const resolve of uploadWaitersRef.current.splice(0)) {
707
+ resolve();
708
+ }
749
709
  }
750
710
  }
751
711
 
@@ -770,228 +730,108 @@ export default function ComposeWindow({
770
730
  const files = Array.from(e.target.files ?? []);
771
731
  e.target.value = "";
772
732
  setAttachError(null);
773
- for (const file of files) {
774
- try {
775
- const attachment = await uploadAttachment(draft!.uid, file);
776
- setHasUploads(true);
777
- setAttachments((prev) => [...prev, attachment]);
778
- void refreshDraftVersion(draft!.uid);
779
- } catch (err) {
780
- setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
733
+ uploadStarted();
734
+ try {
735
+ for (const file of files) {
736
+ try {
737
+ const attachment = await uploadAttachment(draft!.uid, file);
738
+ setHasUploads(true);
739
+ setAttachments((prev) => [...prev, attachment]);
740
+ void refreshDraftVersion(draft!.uid);
741
+ } catch (err) {
742
+ setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
743
+ }
781
744
  }
745
+ } finally {
746
+ uploadFinished();
782
747
  }
783
748
  }
784
749
 
785
750
  /**
786
- * Builds this draft's final body — plaintext, signed-only, or signed+encrypted per
787
- * `specs/end-to-end_encryption.md` — then stores it via `assembleDraft()`/`assembleDraftRaw()`.
788
- * Shared by immediate and scheduled sends (and the banners' override buttons rendered below, via
789
- * `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
751
+ * Send (or, given `scheduledSendTimeIso`, Send later) - and get out of the way. The window is the place the message is *written*; nothing
752
+ * about getting it to the server is waited for here.
790
753
  *
791
- * Returns `"blocked"` (never throws for this case) whenever the message can't go out the way the user
792
- * asked for — never a silent downgrade to plaintext:
793
- * encryption was requested (or auto-applies) but not every recipient can currently be encrypted to -
794
- * the spec's "Multiple Recipients" all-or-nothing rule (sets `encryptionBlocked`); signing/encryption was
795
- * on offer this session but the keys have since locked, or the mailbox/encryption policy needed to decide
796
- * couldn't be loaded (sets `securityBlock`, and re-prompts the unlock dialog when the keys are merely
797
- * locked); or the message would be encrypted but has Bcc recipients - every recipient's certificate goes
798
- * into one shared envelope, which would disclose the Bcc list to every other recipient, and the send API
799
- * has no per-recipient-copy submission to avoid that (sets `securityBlock`).
800
- * Callers must stop (not send) when they get this back. `forcePlaintext` (the user's explicit choice
801
- * from one of those banners) skips every one of these checks and never encrypts; signing still
802
- * happens if it's currently possible.
754
+ * 1. **Validate synchronously**, from what the window already knows (no request): a recipient is required; and if the message is one
755
+ * 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
756
+ * attachment that can't be signed) that is shown right here and the window stays open - the sender fixes it or picks the override.
757
+ * An attachment still uploading is waited for, inline (Send says so), never left behind.
758
+ * 2. **Close immediately** (`onClose()`): `startSend()` (see `sendJob.ts`) takes over with a snapshot of the message. From here the only
759
+ * feedback is the Outbox indicator; a failure at any stage is a sticky pop-up with the reason and Retry / Open draft.
803
760
  *
804
- * Discovery (`lookupKeys()`) and the resulting sign/encrypt decision only run at this, the final
805
- * pre-send step — `checkRecipientDiscovery()`'s on-blur badges are a best-effort hint only.
806
- */
807
- async function assembleForSend(
808
- toRecipients: ComposeRecipientInput[],
809
- ccRecipients: ComposeRecipientInput[],
810
- bccRecipients: ComposeRecipientInput[],
811
- forcePlaintext: boolean,
812
- ): Promise<Message | "blocked"> {
813
- // Keys are read at the moment they're needed, and again after any await: they can lock (idle
814
- // timeout, sign-out elsewhere) mid-send, and a destroyed `UnlockedKeys` object must count as locked.
815
- // Only reachable once a draft exists, which itself requires `mailboxUid` to have resolved.
816
- function readKeys() {
817
- const stored = getUnlockedKeys(mailboxUid!);
818
- const unlocked = stored?.destroyed ? undefined : stored;
819
- return {
820
- unlocked,
821
- // `mailbox` is required to build protected headers (own From address) whenever signing or
822
- // encrypting - not just guarded on the encryption branch below, since a signed-only message
823
- // needs it too.
824
- canSign: signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer,
825
- canEncryptSelf: !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer,
826
- };
827
- }
828
- let keys = readKeys();
829
- const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
830
-
831
- // The Sign toggle was shown (and is still checked), but signing can't actually happen any more.
832
- const signingLost = () => !forcePlaintext && signEnabled && offeredCryptoRef.current.sign && !keys.canSign;
833
- if (signingLost()) {
834
- return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
835
- }
836
-
837
- if (!forcePlaintext && !mailbox) {
838
- // The mailbox's enrolled keys are what say whether this message could be encrypted at all - with keys
839
- // that aren't unlocked in this session, a failed mailbox load would otherwise skip encryption silently.
840
- return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
841
- }
842
-
843
- let wantEncrypt = false;
844
- let recipientCertDers: Uint8Array[] = [];
845
- // Entered whenever this message could be encrypted at all - including a mailbox with an enrolled
846
- // encryption key that this session never unlocked: policy may still auto-encrypt, and that must block
847
- // (and prompt to unlock) rather than quietly send plaintext.
848
- if (!forcePlaintext && (keys.canEncryptSelf || offeredCryptoRef.current.encrypt || hasEnrolledEncryptionKey)) {
849
- if (!encryptionPolicy) {
850
- // Without it, there's no telling whether policy auto-encrypts this message.
851
- return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
852
- }
853
- // Reachable only with `mailbox` loaded (checked above).
854
- const ownPrefersMutual = mailbox!.encryptPreference?.preferEncrypt === "mutual";
855
- const lookups = await Promise.all(
856
- allRecipients.map((r) =>
857
- lookupKeys(mailboxUid!, r.address).then(
858
- (lookup) => ({ lookup }),
859
- () => null,
860
- ),
861
- ),
862
- );
863
- keys = readKeys();
864
- if (signingLost()) {
865
- return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
866
- }
867
- // A failed lookup isn't "no key": treating it as one could send plaintext that policy would encrypt.
868
- if (lookups.some((result) => !result) && (encryptRequested || policyCanAutoEncrypt(encryptionPolicy))) {
869
- return blockSend(LOOKUP_UNAVAILABLE_MESSAGE, "Send without encryption", false);
870
- }
871
- const statuses = allRecipients.map((r, i) =>
872
- resolveRecipientEncryption(mailbox!.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]?.lookup),
873
- );
874
- const decision = decideMessageEncryption(statuses);
875
- wantEncrypt = encryptRequested || decision.autoEncrypt;
876
- if (wantEncrypt) {
877
- if (!keys.canEncryptSelf) {
878
- return blockSend(KEYS_LOCKED_ENCRYPT_MESSAGE, "Send without encryption", !keys.unlocked);
879
- }
880
- if (!decision.canEncryptAll) {
881
- setEncryptionBlocked(decision.blockedRecipients);
882
- return "blocked";
883
- }
884
- if (bccRecipients.length > 0) {
885
- return blockSend(BCC_ENCRYPTED_MESSAGE, "Send without encryption", false);
886
- }
887
- recipientCertDers = [keys.unlocked!.encryptionCertDer!, ...statuses.map((s) => fromBase64(s.encryptCert!.publicKey))];
888
- }
889
- }
890
-
891
- const { canSign } = keys;
892
- if ((wantEncrypt || canSign) && attachments.length > 0) {
893
- // Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
894
- // clearer explanation than that route's generic 400 rather than via a round trip.
895
- throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
896
- }
897
- if ((wantEncrypt || canSign) && INLINE_IMAGE_PATTERN.test(html)) {
898
- throw new ApiRequestError("A signed or encrypted message cannot include inline images yet - remove them before sending.", 400);
899
- }
900
-
901
- if (!wantEncrypt && !canSign) {
902
- // See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
903
- // gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
904
- // client-side pass is done here either.
905
- return assembleDraft(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject, html });
906
- }
907
-
908
- const domain = mailbox!.primarySmtpAddress.split("@")[1] ?? "localhost";
909
- const displayName = mailbox!.displayName && !UNSAFE_DISPLAY_NAME_PATTERN.test(mailbox!.displayName) ? mailbox!.displayName : "";
910
- const protectedHeaders: ProtectedHeaders = {
911
- from: displayName
912
- ? `"${displayName.replace(/"/g, '\\"')}" <${mailbox!.primarySmtpAddress}>`
913
- : mailbox!.primarySmtpAddress,
914
- to: toRecipients.map((r) => r.address).join(", "),
915
- cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
916
- date: new Date().toUTCString(),
917
- subject,
918
- messageId: `<${crypto.randomUUID()}@${domain}>`,
919
- };
920
- const bodyContentType = 'text/html; charset="utf-8"';
921
- const signing = canSign ? { certDer: keys.unlocked!.signingCertDer!, privateKey: keys.unlocked!.signingPrivateKey! } : undefined;
922
-
923
- // The S/MIME code (PKI.js and the ASN.1/X.509 libraries, over half a megabyte) is only needed here, to sign or
924
- // encrypt - a plain message never loads it, and it is kept out of the compose window's own chunk.
925
- const { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } = await import(
926
- "@rapidmx/react-shared/crypto/smimeMessage.js"
927
- );
928
- const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
929
- const mimePart = wantEncrypt
930
- ? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
931
- : await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing!.certDer, signing!.privateKey);
932
- const rawMime = assembleOutboundMime(outerHeaders, mimePart);
933
- return assembleDraftRaw(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
934
- }
935
-
936
- /** Records why a send was refused (see `assembleForSend()`), re-prompting the unlock dialog when the
937
- * cause is keys that are enrolled but locked. */
938
- function blockSend(message: string, overrideLabel: string, keysLocked: boolean): "blocked" {
939
- setSecurityBlock({ message, overrideLabel });
940
- if (keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
941
- void handleUnlockForCrypto();
942
- }
943
- return "blocked";
944
- }
945
-
946
- /**
947
- * Sends (or, given `scheduledSendTimeIso`, schedules) the draft. Same reasoning as `handleFilesSelected`
948
- * above: Send and the "Send later" caret are both `disabled` until `draft` resolves, so this is never
949
- * reachable with a null `draft`. A scheduled send assembles the draft first (same as an immediate send),
950
- * then sets `scheduledSendTime` on the *freshly assembled* copy before calling `sendMessage()` — `send()`
951
- * itself is what reads the field and defers relay into Outbox instead of sending immediately (see
952
- * `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`). A blocked send remembers its schedule
953
- * time so the banners' override buttons replay it as the same kind of send.
761
+ * Pressed twice (a double click, the shortcut repeated) it sends once: the first press sets `submittingRef` before any `await`, and
762
+ * `startSend()` itself refuses a draft that is already being sent. `forcePlaintext` is the override buttons' choice.
954
763
  */
955
764
  async function submit(forcePlaintext: boolean, scheduledSendTimeIso?: string) {
956
- const toRecipients = parseAddresses(to);
957
- if (toRecipients.length === 0) {
765
+ if (submittingRef.current || finishedRef.current) {
766
+ return;
767
+ }
768
+ if (parseAddresses(latestRef.current.to).length === 0) {
958
769
  setSendError("At least one recipient is required.");
959
770
  return;
960
771
  }
961
-
962
- setSending(true);
963
- sendingRef.current = true;
772
+ submittingRef.current = true;
964
773
  setSendError(null);
965
- setSendDetails([]);
966
774
  setSecurityBlock(null);
967
- setEncryptionBlocked(null);
968
- pendingSaveRef.current = false;
969
775
  try {
970
- // An autosave already on the wire must never land after (and overwrite) the send's own assembly.
971
- await saveInFlightRef.current;
972
- const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), forcePlaintext);
973
- if (assembled === "blocked") {
776
+ if (uploadsInFlightRef.current > 0) {
777
+ setWaitingForUploads(true);
778
+ await new Promise<void>((resolve) => uploadWaitersRef.current.push(resolve));
779
+ // The editor puts an image it has just uploaded into the message a moment after the upload resolves: let the window catch up.
780
+ await new Promise((resolve) => setTimeout(resolve, 0));
781
+ setWaitingForUploads(false);
782
+ }
783
+ const current = latestRef.current;
784
+ const request: SendRequest = {
785
+ draft: current.draft!,
786
+ mailboxUid: current.mailboxUid!,
787
+ mailbox: current.mailbox ?? undefined,
788
+ policy: current.encryptionPolicy ?? undefined,
789
+ toText: current.to,
790
+ ccText: current.cc,
791
+ bccText: current.bcc,
792
+ to: parseAddresses(current.to),
793
+ cc: parseAddresses(current.cc),
794
+ bcc: parseAddresses(current.bcc),
795
+ subject: current.subject,
796
+ html: current.html,
797
+ attachments: current.attachments,
798
+ requestReceipt: current.requestReceipt,
799
+ scheduledSendTime: scheduledSendTimeIso,
800
+ signEnabled: current.signEnabled,
801
+ offeredSign: offeredCryptoRef.current.sign,
802
+ offeredEncrypt: offeredCryptoRef.current.encrypt,
803
+ encryptRequested: current.encryptRequested,
804
+ forcePlaintext,
805
+ saved: saveInFlightRef.current,
806
+ };
807
+ if (request.to.length === 0) {
808
+ setSendError("At least one recipient is required.");
809
+ return;
810
+ }
811
+ const early = decideSend(sendDecisionInput(request, request.mailbox, request.policy, current.statuses, false));
812
+ if (early.action === "rejected") {
813
+ setSendError(early.message);
814
+ return;
815
+ }
816
+ if (early.action === "blocked") {
974
817
  setBlockedScheduleIso(scheduledSendTimeIso);
818
+ setSecurityBlock(early.block);
819
+ if (early.block.keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
820
+ void handleUnlockForCrypto();
821
+ }
975
822
  return;
976
823
  }
977
- // The draft now holds exactly this content (possibly signed/encrypted) - don't autosave a
978
- // plaintext copy over it if the rest of the send fails.
979
- lastSavedRef.current = `${assembled.uid}:${contentKey}`;
980
- const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
981
- await sendMessage(withReceipt.uid, scheduledSendTimeIso ? { scheduledSendTime: scheduledSendTimeIso } : undefined);
824
+ pendingSaveRef.current = false;
982
825
  finishedRef.current = true;
826
+ if (!startSend(request)) {
827
+ finishedRef.current = false;
828
+ setSendError("This message is already being sent.");
829
+ return;
830
+ }
983
831
  onClose();
984
- } catch (err) {
985
- // The window stays open with the draft exactly as it was; nothing here closes, discards or saves over it.
986
- const failure = describeSendFailure(
987
- err,
988
- scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.",
989
- );
990
- setSendError(failure.message);
991
- setSendDetails(failure.lines);
992
832
  } finally {
993
- sendingRef.current = false;
994
- setSending(false);
833
+ submittingRef.current = false;
834
+ setWaitingForUploads(false);
995
835
  }
996
836
  }
997
837
 
@@ -1003,7 +843,7 @@ export default function ComposeWindow({
1003
843
  async function handleUnlockForCrypto() {
1004
844
  try {
1005
845
  // Only reachable when hasEnrolledSigningKey/hasEnrolledEncryptionKey found a real enrolled key
1006
- // in mailbox.keys (the unlock button below, or blockSend()), so neither is empty/undefined here.
846
+ // in mailbox.keys (the unlock button below, or a blocked send()), so neither is empty/undefined here.
1007
847
  await requestUnlock(mailbox!.uid, mailbox!.keys!);
1008
848
  setUnlockRefresh((n) => n + 1);
1009
849
  } catch {
@@ -1018,7 +858,7 @@ export default function ComposeWindow({
1018
858
  * `latestRef` only once it actually runs, so it saves what was last typed (skipping the request when the
1019
859
  * save before it already stored exactly that). Whether a save may happen at all is re-checked at that same
1020
860
  * moment: while it waited, the message may have become one that must not be stored as plaintext (Encrypt
1021
- * turned on, a recipient that auto-encrypts), or the window may have started sending or finished - then it
861
+ * turned on, a recipient that auto-encrypts), or the window may have been sent or finished - then it
1022
862
  * resolves `{ skipped: true }` without a request (`message` then being the previous save's result). Never
1023
863
  * rejects; `message` is `undefined` when the save failed.
1024
864
  */
@@ -1030,7 +870,7 @@ export default function ComposeWindow({
1030
870
  const outcome = (async (): Promise<{ message?: Message; skipped?: boolean }> => {
1031
871
  const before = await previous;
1032
872
  const current = latestRef.current;
1033
- if (current.autosaveSuppressed || sendingRef.current || finishedRef.current) {
873
+ if (current.autosaveSuppressed || finishedRef.current) {
1034
874
  setSaveStatus("idle");
1035
875
  // Still the newest copy the server holds, for the version a later delete needs.
1036
876
  return { skipped: true, message: before };
@@ -1150,39 +990,19 @@ export default function ComposeWindow({
1150
990
  }
1151
991
 
1152
992
  /** "Close": keeps the draft (saving any unsaved edits first, and closing only once that save succeeded) -
1153
- * unless there's nothing in it worth keeping, or it can't be saved because it's headed for encryption, or
1154
- * whether it is can't be told yet (settings loading or unavailable, recipient lookups pending): those need
1155
- * the same confirmation as a discard. A failed save keeps the window open with Discard / Keep editing.
1156
- * Close and Discard are both disabled while a send is in progress: deleting the draft, or saving a
1157
- * plaintext copy over the message the send just assembled, mid-send would lose or leak it. */
1158
- async function handleClose(sendFailureAcknowledged = false) {
993
+ * unless there's nothing in it worth keeping, or it can't be saved because it's really headed for encryption
994
+ * (asked for, or applied by the loaded policy): that needs the same confirmation as a discard. Whatever is
995
+ * *unknown* (settings loading or failed, a lookup pending) is not a reason to hold the window: it is saved like any
996
+ * other message. A failed save keeps the window open with Discard / Keep editing. */
997
+ async function handleClose() {
1159
998
  if (!hasUserContent) {
1160
999
  void discardNow();
1161
1000
  return;
1162
1001
  }
1163
- if (sendError && !sendFailureAcknowledged) {
1164
- // Closing would keep the draft and say nothing, which reads as if it had been sent.
1165
- setClosePrompt({ title: SEND_FAILED_TITLE, message: sendFailedCloseMessage(sendError), closeAnyway: true });
1166
- return;
1167
- }
1168
1002
  if (encryptionDecided) {
1169
1003
  setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
1170
1004
  return;
1171
1005
  }
1172
- if (cryptoCheckUnavailable) {
1173
- setClosePrompt({ title: DISCARD_TITLE, message: CRYPTO_UNAVAILABLE_MESSAGE, retry: true });
1174
- return;
1175
- }
1176
- if (awaitingRecipients) {
1177
- setClosePrompt({ title: DISCARD_TITLE, message: NO_RECIPIENTS_CLOSE_MESSAGE });
1178
- return;
1179
- }
1180
- if (encryptionUndetermined) {
1181
- // A recipient typed but never blurred has no lookup running yet.
1182
- checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
1183
- setClosePrompt({ title: DISCARD_TITLE, message: CHECKING_CLOSE_MESSAGE });
1184
- return;
1185
- }
1186
1006
  if (!draft || !contentReady) {
1187
1007
  saveErrorRef.current = draftError ?? "the draft hasn't been created yet";
1188
1008
  showSaveFailedPrompt();
@@ -1194,7 +1014,7 @@ export default function ComposeWindow({
1194
1014
  setClosing(false);
1195
1015
  if (outcome.skipped) {
1196
1016
  // It became a message that mustn't be saved as plaintext while waiting on an earlier save.
1197
- setClosePrompt({ title: DISCARD_TITLE, message: latestRef.current.encryptionDecided ? ENCRYPTED_CLOSE_MESSAGE : CHECKING_CLOSE_MESSAGE });
1017
+ setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
1198
1018
  return;
1199
1019
  }
1200
1020
  if (!outcome.message) {
@@ -1206,14 +1026,6 @@ export default function ComposeWindow({
1206
1026
  onClose();
1207
1027
  }
1208
1028
 
1209
- function retryCryptoContext() {
1210
- setClosePrompt(null);
1211
- setCryptoRetryToken((n) => n + 1);
1212
- lookupFailuresRef.current = new Map();
1213
- setExhaustedLookups({});
1214
- setLookupRetryToken((n) => n + 1);
1215
- }
1216
-
1217
1029
  // A plain read from keySession.ts's module-level session store, not React state - see that module's
1218
1030
  // own doc comment. Cheap enough to read fresh on every render rather than caching in state.
1219
1031
  const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
@@ -1248,29 +1060,65 @@ export default function ComposeWindow({
1248
1060
  const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
1249
1061
  const baselineKey = JSON.stringify([baselineTo ?? "", baselineCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
1250
1062
  const hasUserContent = contentKey !== baselineKey || hasUploads;
1251
- // A message headed for encryption is never autosaved: the draft would store its plaintext server-side,
1252
- // defeating end-to-end encryption. Until it's known not to be, it isn't either: while the mailbox (whose
1253
- // enrolled keys say whether encryption is possible at all) or the encryption policy hasn't loaded, and -
1254
- // whenever this message could be encrypted (an unlocked or enrolled encryption key) - while the policy is
1255
- // missing or any current recipient's lookup is still pending (or failed, or hasn't run) - and while there are no
1256
- // recipients at all when policy auto-encrypts some tier, since the first one added may turn encryption on.
1063
+ // Whether this message is *really* headed for encryption - `encryptionRequirement.ts` decides, and it fails open: the message is plain (its
1064
+ // 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
1065
+ // recipient (whose keys were all looked up). What has not loaded, or failed to, is not held against the message.
1066
+ //
1067
+ // Transitions, when a later load turns a plain message into an encrypted one (the policy arrives, a slow lookup answers, a recipient is
1068
+ // added): (1) autosave stops at once (`autosaveSuppressed`); (2) a plaintext copy this window already saved is *replaced by an empty draft*
1069
+ // (`scrubSavedDraft()` below) so the plaintext doesn't sit on the server; (3) Close asks first ("Encrypted messages aren't saved as drafts");
1070
+ // (4) Send encrypts (or is refused, with the override, when it can't) - never silently plaintext. And back: when it stops being encrypted
1071
+ // (a recipient removed) autosave simply resumes.
1257
1072
  const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
1258
1073
  const currentStatuses = currentAddresses.map((address) => recipientStatuses[address]);
1259
- const encryptionPossible = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
1260
- /** Could be auto-encrypted, but has no recipients yet to decide by. */
1261
- const awaitingRecipients = encryptionPossible && !!encryptionPolicy && currentAddresses.length === 0 && policyCanAutoEncrypt(encryptionPolicy);
1262
- const encryptionUndetermined =
1263
- !cryptoContextReady || !mailbox || awaitingRecipients || (encryptionPossible && (!encryptionPolicy || currentStatuses.some((s) => !s)));
1264
- /** Known to be headed for encryption: requested, or policy auto-encrypts it. */
1265
- const encryptionDecided = encryptRequested || (!encryptionUndetermined && encryptionPossible && decideMessageEncryption(currentStatuses).autoEncrypt);
1266
- const autosaveSuppressed = encryptionDecided || encryptionUndetermined;
1267
- /** Loading the mailbox/policy failed (retries may still be pending) and this message's encryption hinges on it, or
1268
- * a current recipient's key lookup kept failing. */
1269
- const cryptoCheckUnavailable =
1270
- (cryptoContextReady && cryptoLoadFailed && (!mailbox || (encryptionPossible && !encryptionPolicy))) ||
1271
- (encryptionPossible && currentAddresses.some((address) => exhaustedLookups[address] && !recipientStatuses[address]));
1272
- const latestRef = useRef({ draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided });
1273
- latestRef.current = { draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided };
1074
+ const hasEncryptionKey = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
1075
+ const requirement = evaluateEncryptionRequirement({
1076
+ encryptRequested,
1077
+ policy: encryptionPolicy ?? undefined,
1078
+ mailboxLoaded: !!mailbox,
1079
+ hasEncryptionKey,
1080
+ recipients: currentStatuses,
1081
+ });
1082
+ const encryptionDecided = requirement.required;
1083
+ const autosaveSuppressed = encryptionDecided;
1084
+ const latestRef = useRef({
1085
+ draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
1086
+ statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
1087
+ });
1088
+ latestRef.current = {
1089
+ draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
1090
+ statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
1091
+ };
1092
+
1093
+ /** 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
1094
+ * uid, so attachments and the folder are undisturbed), chained after any save still on the wire. Best effort - a failure is reported once. */
1095
+ function scrubSavedDraft(target: Message) {
1096
+ lastSavedRef.current = null;
1097
+ setSaveStatus("idle");
1098
+ const previous = saveInFlightRef.current;
1099
+ const scrub = (async (): Promise<Message | undefined> => {
1100
+ await previous;
1101
+ try {
1102
+ // Its answer (the new version) is what a later delete waits for through `saveInFlightRef`, like any save's.
1103
+ return await assembleDraft(target.uid, { to: [], cc: [], bcc: [], subject: "", html: "" });
1104
+ } catch {
1105
+ notify({
1106
+ id: `scrub-draft:${target.uid}`,
1107
+ kind: "warning",
1108
+ title: "An earlier draft copy is still saved",
1109
+ message: "This message needs encryption, but the copy saved before that was known couldn't be replaced. Discarding the draft removes it.",
1110
+ });
1111
+ return undefined;
1112
+ }
1113
+ })();
1114
+ saveInFlightRef.current = scrub;
1115
+ }
1116
+ // Only a message that was really saved (`lastSavedRef` names its draft) has a plaintext copy to replace.
1117
+ useEffect(() => {
1118
+ if (encryptionDecided && draft && lastSavedRef.current?.startsWith(`${draft.uid}:`)) {
1119
+ scrubSavedDraft(draft);
1120
+ }
1121
+ }, [encryptionDecided, draft?.uid]);
1274
1122
 
1275
1123
  // Recipients a reply could only work out after this window opened (Reply All: whoever the original's headers name)
1276
1124
  // replace the ones it opened with - but only in a field the user hasn't touched, never over what they typed.
@@ -1292,8 +1140,6 @@ export default function ComposeWindow({
1292
1140
  const due =
1293
1141
  !!draft &&
1294
1142
  contentReady &&
1295
- cryptoContextReady &&
1296
- !sending &&
1297
1143
  !autosaveSuppressed &&
1298
1144
  hasUserContent &&
1299
1145
  lastSavedRef.current !== `${draft.uid}:${contentKey}`;
@@ -1303,7 +1149,7 @@ export default function ComposeWindow({
1303
1149
  }
1304
1150
  const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
1305
1151
  return () => clearTimeout(timer);
1306
- }, [draft, contentReady, cryptoContextReady, sending, autosaveSuppressed, hasUserContent, contentKey]);
1152
+ }, [draft, contentReady, autosaveSuppressed, hasUserContent, contentKey]);
1307
1153
 
1308
1154
  // Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
1309
1155
  // right away rather than losing it.
@@ -1352,7 +1198,7 @@ export default function ComposeWindow({
1352
1198
  // out - that navigation must not be cancellable.
1353
1199
  useEffect(() => {
1354
1200
  function handleBeforeUnload(event: BeforeUnloadEvent) {
1355
- if (finishedRef.current || sendingRef.current || isSigningOut()) {
1201
+ if (finishedRef.current || isSigningOut()) {
1356
1202
  return;
1357
1203
  }
1358
1204
  const latest = latestRef.current;
@@ -1374,7 +1220,7 @@ export default function ComposeWindow({
1374
1220
  // Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
1375
1221
  // with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
1376
1222
  // window must not move the caret there again.
1377
- const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending;
1223
+ const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending || !!resume;
1378
1224
  const fieldsFocusedRef = useRef(false);
1379
1225
  const bodyFocusedRef = useRef(false);
1380
1226
  const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
@@ -1410,13 +1256,15 @@ export default function ComposeWindow({
1410
1256
  // Keyboard shortcuts, active while the focus is inside this window (`container`; the scope attribute on its root is what tells the
1411
1257
  // 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 -
1412
1258
  // a shortcut pressed while its button would be disabled is consumed and does nothing.
1413
- const canSend = !!draft && !sending && !closing && cryptoContextReady;
1259
+ // Send never waits for the encryption settings to load (fail open, see `encryptionRequirement.ts`); it only needs the draft to exist. Pressed
1260
+ // while an earlier press is still being handled it does nothing (`submit()` guards that itself).
1261
+ const canSend = !!draft && !closing && !waitingForUploads;
1414
1262
  useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
1415
1263
  useShortcut(
1416
1264
  SHORTCUTS.compose.saveDraft,
1417
1265
  () => {
1418
- // Autosave's own conditions: never a draft that must not be stored as plaintext, and nothing while a send is on the wire.
1419
- if (draft && contentReady && cryptoContextReady && !sending && !closing && !autosaveSuppressed && hasUserContent) {
1266
+ // Autosave's own conditions: never a draft that must not be stored as plaintext.
1267
+ if (draft && contentReady && !closing && !autosaveSuppressed && hasUserContent) {
1420
1268
  void saveDraftNow();
1421
1269
  }
1422
1270
  },
@@ -1425,7 +1273,7 @@ export default function ComposeWindow({
1425
1273
  useShortcut(
1426
1274
  SHORTCUTS.compose.close,
1427
1275
  () => {
1428
- if (!sending && !closing) {
1276
+ if (!closing) {
1429
1277
  void handleClose();
1430
1278
  }
1431
1279
  },
@@ -1440,25 +1288,7 @@ export default function ComposeWindow({
1440
1288
  <Modal open={closePrompt !== null} onClose={() => setClosePrompt(null)} title={closePrompt?.title ?? DISCARD_TITLE}>
1441
1289
  <p className="text-sm text-text-muted mb-4">{closePrompt?.message}</p>
1442
1290
  <div className="flex gap-3">
1443
- {closePrompt?.retry && (
1444
- <Button type="button" onClick={retryCryptoContext} className="!w-auto">
1445
- Retry
1446
- </Button>
1447
- )}
1448
- {closePrompt?.closeAnyway && (
1449
- <Button
1450
- type="button"
1451
- onClick={() => {
1452
- setClosePrompt(null);
1453
- void handleClose(true);
1454
- }}
1455
- disabled={sending || closing}
1456
- className="!w-auto"
1457
- >
1458
- Close, keep draft
1459
- </Button>
1460
- )}
1461
- <Button type="button" onClick={() => void discardNow()} disabled={sending || closing} className="!w-auto">
1291
+ <Button type="button" onClick={() => void discardNow()} disabled={closing} className="!w-auto">
1462
1292
  Discard
1463
1293
  </Button>
1464
1294
  <Button type="button" variant="secondary" onClick={() => setClosePrompt(null)} className="!w-auto">
@@ -1477,10 +1307,9 @@ export default function ComposeWindow({
1477
1307
  onClick={onToggleMinimize}
1478
1308
  >
1479
1309
  <span className="text-sm font-medium truncate">{title}</span>
1480
- {sendError && <span className="shrink-0 text-xs font-bold uppercase tracking-wide">Not sent</span>}
1481
1310
  <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
1482
1311
  <HeaderButton label="Restore" onClick={onToggleMinimize} icon={HiOutlineArrowsPointingOut} />
1483
- <HeaderButton label="Discard draft" onClick={handleDiscard} icon={HiOutlineXMark} disabled={sending || closing} />
1312
+ <HeaderButton label="Discard draft" onClick={handleDiscard} icon={HiOutlineXMark} disabled={closing} />
1484
1313
  </div>
1485
1314
  </div>
1486
1315
  </div>
@@ -1541,55 +1370,28 @@ export default function ComposeWindow({
1541
1370
  icon={expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut}
1542
1371
  />
1543
1372
  )}
1544
- <HeaderButton label="Close" onClick={() => void handleClose()} icon={HiOutlineXMark} disabled={sending || closing} shortcut={SHORTCUTS.compose.close} />
1373
+ <HeaderButton label="Close" onClick={() => void handleClose()} icon={HiOutlineXMark} disabled={closing} shortcut={SHORTCUTS.compose.close} />
1545
1374
  </div>
1546
1375
  </div>
1547
1376
 
1548
1377
  <div className="flex-1 min-h-0 flex flex-col">
1549
1378
  {(folderError || draftError || sendError || attachError || discardError) && (
1550
- // Scrolls inside the window when it is tall (a failed send's expanded Technical details), instead of pushing the
1551
- // editor and the Send button out of the window's fixed height, where they can't be reached.
1379
+ // Scrolls inside the window when it is tall, instead of pushing the editor and the Send button out of the window's
1380
+ // fixed height, where they can't be reached.
1552
1381
  <div className="px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto">
1553
1382
  {folderError && <Alert>{folderError}</Alert>}
1554
1383
  {draftError && <Alert>{draftError}</Alert>}
1555
- {sendError && <SendFailureAlert message={sendError} lines={sendDetails} />}
1384
+ {sendError && <Alert>{sendError}</Alert>}
1556
1385
  {attachError && <Alert>{attachError}</Alert>}
1557
1386
  {discardError && <Alert>{discardError}</Alert>}
1558
1387
  </div>
1559
1388
  )}
1560
1389
 
1561
- {cryptoCheckUnavailable && (
1562
- <div className="px-3 pt-2">
1563
- <Alert>
1564
- <p className="mb-2">{CRYPTO_UNAVAILABLE_MESSAGE}</p>
1565
- <Button type="button" variant="secondary" className="!w-auto" onClick={retryCryptoContext}>
1566
- Retry
1567
- </Button>
1568
- </Alert>
1569
- </div>
1570
- )}
1571
-
1572
- {encryptionBlocked && (
1573
- <div className="px-3 pt-2">
1574
- <Alert>
1575
- <p className="mb-2">
1576
- This message can&rsquo;t be encrypted for everyone: {encryptionBlocked.map((r) => r.address).join(", ")}
1577
- {encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file"}.
1578
- Remove {encryptionBlocked.length > 1 ? "these recipients" : "this recipient"} from To/Cc/Bcc, or send the
1579
- whole message in plaintext.
1580
- </p>
1581
- <Button type="button" disabled={sending} onClick={() => void submit(true, blockedScheduleIso)}>
1582
- Send without encryption
1583
- </Button>
1584
- </Alert>
1585
- </div>
1586
- )}
1587
-
1588
1390
  {securityBlock && (
1589
1391
  <div className="px-3 pt-2">
1590
1392
  <Alert>
1591
1393
  <p className="mb-2">{securityBlock.message}</p>
1592
- <Button type="button" disabled={sending} onClick={() => void submit(true, blockedScheduleIso)}>
1394
+ <Button type="button" onClick={() => void submit(true, blockedScheduleIso)}>
1593
1395
  {securityBlock.overrideLabel}
1594
1396
  </Button>
1595
1397
  </Alert>
@@ -1605,7 +1407,7 @@ export default function ComposeWindow({
1605
1407
  id={`compose-from-${id}`}
1606
1408
  className={`${FIELD_INPUT} disabled:opacity-55`}
1607
1409
  value={mailboxUid ?? ""}
1608
- disabled={hasUploads || sending}
1410
+ disabled={hasUploads}
1609
1411
  title={hasUploads ? "The sender can't be changed after adding attachments or images." : undefined}
1610
1412
  onChange={(e) => handleFromChange(e.target.value)}
1611
1413
  >
@@ -1762,7 +1564,7 @@ export default function ComposeWindow({
1762
1564
  )}
1763
1565
 
1764
1566
  {/* Only rendered once this session actually has a usable signing/encryption key - see
1765
- `assembleForSend()`'s own doc comment for why signing rarely shows today (no signing
1567
+ `sendJob.ts`'s signing path for why signing rarely shows today (no signing
1766
1568
  certificate exists until RFC 8823 ACME enrollment lands). */}
1767
1569
  {unlockedKeys?.signingPrivateKey && (
1768
1570
  <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
@@ -1784,10 +1586,10 @@ export default function ComposeWindow({
1784
1586
  onClick={() => void submit(false)}
1785
1587
  title={withHint("Send", SHORTCUTS.compose.send, keyEnv)}
1786
1588
  aria-keyshortcuts={ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv)}
1787
- disabled={!draft || sending || closing || !cryptoContextReady}
1589
+ disabled={!draft || closing || waitingForUploads}
1788
1590
  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"
1789
1591
  >
1790
- {sending ? "Sending…" : "Send"}
1592
+ Send
1791
1593
  </button>
1792
1594
  <button
1793
1595
  ref={scheduleButtonRef}
@@ -1795,7 +1597,7 @@ export default function ComposeWindow({
1795
1597
  aria-label="Send later"
1796
1598
  aria-haspopup="true"
1797
1599
  aria-expanded={schedulePickerOpen}
1798
- disabled={!draft || sending || closing || !cryptoContextReady}
1600
+ disabled={!draft || closing || waitingForUploads}
1799
1601
  onClick={() => setSchedulePickerOpen((o) => !o)}
1800
1602
  className="py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
1801
1603
  >
@@ -1820,7 +1622,7 @@ export default function ComposeWindow({
1820
1622
  </label>
1821
1623
 
1822
1624
  <span role="status" aria-live="polite" className="ml-auto text-xs text-text-muted">
1823
- {SAVE_STATUS_LABEL[saveStatus]}
1625
+ {waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus]}
1824
1626
  </span>
1825
1627
 
1826
1628
  <button
@@ -1828,7 +1630,7 @@ export default function ComposeWindow({
1828
1630
  aria-label="Discard draft"
1829
1631
  title="Discard draft"
1830
1632
  onClick={handleDiscard}
1831
- disabled={sending || closing}
1633
+ disabled={closing}
1832
1634
  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"
1833
1635
  >
1834
1636
  <HiOutlineTrash size={18} />