@rapidmx/web-client 0.9.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 (443) hide show
  1. package/README.md +373 -88
  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/auth/accountUrl.ts +9 -0
  21. package/apps/shared/auth/adminAccess.ts +99 -0
  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 +2 -1
  26. package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
  27. package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
  28. package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
  29. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  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 +98 -115
  33. package/apps/shared/components/contacts/layout/ContactsShell.tsx +25 -8
  34. package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
  35. package/apps/shared/components/layout/AppShell.tsx +477 -288
  36. package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
  37. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  38. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  39. package/apps/shared/components/layout/RailIcon.tsx +100 -0
  40. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
  41. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
  42. package/apps/shared/components/layout/UserMenu.tsx +372 -185
  43. package/apps/shared/components/mail/ConversationList.tsx +271 -262
  44. package/apps/shared/components/mail/ConversationThreadPane.tsx +147 -94
  45. package/apps/shared/components/mail/LazyReadingPane.tsx +113 -0
  46. package/apps/shared/components/mail/MailSelectionBar.tsx +227 -222
  47. package/apps/shared/components/mail/MessageDetailPane.tsx +1537 -1387
  48. package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
  49. package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
  50. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -160
  51. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  52. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1644 -1736
  53. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  54. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  55. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  56. package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
  57. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  58. package/apps/shared/components/mail/layout/MailShell.tsx +490 -472
  59. package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
  60. package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
  61. package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
  62. package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
  63. package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
  64. package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
  65. package/apps/shared/components/mail/reading/color.ts +245 -0
  66. package/apps/shared/components/mail/reading/frameControl.ts +142 -0
  67. package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
  68. package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
  69. package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
  70. package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
  71. package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
  72. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  73. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
  74. package/apps/shared/components/settings/layout/SettingsShell.tsx +253 -240
  75. package/apps/shared/components/sharing/PrincipalPicker.tsx +145 -0
  76. package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
  77. package/apps/shared/components/tasks/layout/TasksShell.tsx +24 -8
  78. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  79. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  80. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  81. package/apps/shared/keyboard/dispatch.ts +124 -0
  82. package/apps/shared/keyboard/format.ts +89 -0
  83. package/apps/shared/keyboard/keymap.ts +114 -0
  84. package/apps/shared/keyboard/match.ts +44 -0
  85. package/apps/shared/keyboard/parse.ts +136 -0
  86. package/apps/shared/keyboard/platform.ts +34 -0
  87. package/apps/shared/keyboard/registry.ts +65 -0
  88. package/apps/shared/keyboard/targets.ts +79 -0
  89. package/apps/shared/keyboard/useShortcut.ts +50 -0
  90. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  91. package/apps/shared/mail/folderCounts.ts +308 -0
  92. package/apps/shared/mail/folderTree.ts +143 -0
  93. package/apps/shared/mail/listSnapshots.ts +87 -0
  94. package/apps/shared/mail/messageReadState.ts +85 -0
  95. package/apps/shared/mail/newMailNotifications.ts +183 -0
  96. package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
  97. package/apps/shared/mail/outbox/composeBridge.ts +69 -0
  98. package/apps/shared/mail/outbox/outboxState.ts +88 -0
  99. package/apps/shared/mail/outbox/pendingSends.ts +146 -0
  100. package/apps/shared/mail/outbox/sendDecision.ts +157 -0
  101. package/apps/shared/mail/outbox/sendJob.ts +427 -0
  102. package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
  103. package/apps/shared/mail/outbox/sendState.ts +35 -0
  104. package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
  105. package/apps/shared/mail/useMailConnection.ts +203 -0
  106. package/apps/shared/mail/useMailLiveUpdates.ts +277 -224
  107. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  108. package/apps/shared/mail/useNewMailNotifications.ts +178 -0
  109. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  110. package/apps/shared/navigation/AppRouter.tsx +300 -0
  111. package/apps/shared/navigation/appHrefs.ts +23 -0
  112. package/apps/shared/navigation/frameContext.tsx +35 -0
  113. package/apps/shared/navigation/idle.ts +45 -0
  114. package/apps/shared/navigation/routerContext.tsx +83 -0
  115. package/apps/shared/navigation/routes.ts +72 -0
  116. package/apps/shared/notifications/NotificationCenter.tsx +215 -0
  117. package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
  118. package/apps/shared/notifications/apiErrors.ts +98 -0
  119. package/apps/shared/notifications/headerOffset.ts +35 -0
  120. package/apps/shared/notifications/pushStatus.ts +56 -0
  121. package/apps/shared/notifications/store.ts +549 -0
  122. package/apps/shared/notifications/systemErrors.ts +63 -0
  123. package/apps/shared/notifications/useNotifications.ts +41 -0
  124. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  125. package/apps/shared/signing/enrollmentStorage.ts +33 -0
  126. package/apps/shared/signing/enrollmentTracker.ts +385 -0
  127. package/apps/shared/signing/enrollmentView.ts +251 -0
  128. package/apps/shared/signing/signingInfo.ts +58 -0
  129. package/apps/shared/signing/useNow.ts +19 -0
  130. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
  131. package/apps/shared/styles/app.css +108 -10
  132. package/apps/www/_layout.tsx +8 -2
  133. package/apps/www/_routedPage.tsx +24 -0
  134. package/apps/www/_routes.ts +36 -0
  135. package/apps/www/calendar/index.tsx +64 -33
  136. package/apps/www/contacts/[uid].tsx +109 -107
  137. package/apps/www/contacts/index.tsx +62 -42
  138. package/apps/www/index.tsx +2291 -1917
  139. package/apps/www/messages/[uid].tsx +108 -101
  140. package/apps/www/settings/appearance/index.tsx +21 -0
  141. package/apps/www/settings/auto-reply/index.tsx +7 -8
  142. package/apps/www/settings/encryption/index.tsx +1287 -1249
  143. package/apps/www/settings/filters/[uid].tsx +7 -2
  144. package/apps/www/settings/filters/index.tsx +102 -99
  145. package/apps/www/settings/filters/new/index.tsx +140 -133
  146. package/apps/www/settings/labels/index.tsx +204 -201
  147. package/apps/www/settings/privacy/index.tsx +7 -9
  148. package/apps/www/settings/read-receipts/index.tsx +7 -8
  149. package/apps/www/settings/sharing/index.tsx +256 -274
  150. package/apps/www/settings/signatures/[uid].tsx +172 -167
  151. package/apps/www/settings/signatures/index.tsx +88 -85
  152. package/apps/www/settings/signatures/new/index.tsx +135 -129
  153. package/apps/www/tasks/index.tsx +29 -18
  154. package/dist/apps/admin/_layout.d.ts +5 -1
  155. package/dist/apps/admin/_layout.js +3 -2
  156. package/dist/apps/admin/index.js +3 -2
  157. package/dist/apps/admin/ingest-queue/index.js +2 -1
  158. package/dist/apps/admin/mailboxes/[uid].js +9 -6
  159. package/dist/apps/admin/quarantine/index.js +2 -1
  160. package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
  161. package/dist/apps/admin/signing-certificates/index.js +161 -0
  162. package/dist/apps/escrow/_layout.d.ts +5 -1
  163. package/dist/apps/escrow/_layout.js +3 -2
  164. package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
  165. package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
  166. package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
  167. package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
  168. package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
  169. package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
  170. package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
  171. package/dist/apps/shared/appearance/ColorField.js +40 -0
  172. package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
  173. package/dist/apps/shared/appearance/appearanceCache.js +80 -0
  174. package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
  175. package/dist/apps/shared/appearance/appearanceContext.js +29 -0
  176. package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
  177. package/dist/apps/shared/appearance/bootScript.js +33 -0
  178. package/dist/apps/shared/appearance/color.d.ts +65 -0
  179. package/dist/apps/shared/appearance/color.js +132 -0
  180. package/dist/apps/shared/appearance/photo.d.ts +17 -0
  181. package/dist/apps/shared/appearance/photo.js +79 -0
  182. package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
  183. package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
  184. package/dist/apps/shared/appearance/theme.d.ts +111 -0
  185. package/dist/apps/shared/appearance/theme.js +251 -0
  186. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  187. package/dist/apps/shared/auth/accountUrl.js +8 -0
  188. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  189. package/dist/apps/shared/auth/adminAccess.js +89 -0
  190. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  191. package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
  192. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
  193. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
  194. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
  195. package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
  196. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  197. package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
  198. package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
  199. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
  200. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  201. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  202. package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
  203. package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
  204. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +6 -2
  205. package/dist/apps/shared/components/contacts/ContactsToolbar.js +28 -10
  206. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  207. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +19 -6
  208. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
  209. package/dist/apps/shared/components/layout/AppShell.d.ts +34 -4
  210. package/dist/apps/shared/components/layout/AppShell.js +122 -17
  211. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
  212. package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
  213. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  214. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  215. package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
  216. package/dist/apps/shared/components/layout/RailIcon.js +85 -0
  217. package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
  218. package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
  219. package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
  220. package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
  221. package/dist/apps/shared/components/layout/UserMenu.d.ts +25 -3
  222. package/dist/apps/shared/components/layout/UserMenu.js +88 -7
  223. package/dist/apps/shared/components/mail/ConversationList.js +5 -11
  224. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +6 -2
  225. package/dist/apps/shared/components/mail/ConversationThreadPane.js +87 -40
  226. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  227. package/dist/apps/shared/components/mail/LazyReadingPane.js +83 -0
  228. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -4
  229. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  230. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +25 -8
  231. package/dist/apps/shared/components/mail/MessageDetailPane.js +167 -73
  232. package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
  233. package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
  234. package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
  235. package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
  236. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +38 -0
  237. package/dist/apps/shared/components/mail/compose/ComposeContext.js +97 -6
  238. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  239. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +3 -2
  240. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +316 -339
  241. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  242. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  243. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  244. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  245. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  246. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  247. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
  248. package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
  249. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  250. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  251. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +14 -4
  252. package/dist/apps/shared/components/mail/layout/MailShell.js +90 -104
  253. package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
  254. package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
  255. package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
  256. package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
  257. package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
  258. package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
  259. package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
  260. package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
  261. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
  262. package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
  263. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
  264. package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
  265. package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
  266. package/dist/apps/shared/components/mail/reading/color.js +201 -0
  267. package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
  268. package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
  269. package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
  270. package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
  271. package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
  272. package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
  273. package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
  274. package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
  275. package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
  276. package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
  277. package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
  278. package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
  279. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  280. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  281. package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
  282. package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
  283. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  284. package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -5
  285. package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +26 -0
  286. package/dist/apps/shared/components/sharing/PrincipalPicker.js +77 -0
  287. package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
  288. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  289. package/dist/apps/shared/components/tasks/layout/TasksShell.js +18 -6
  290. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  291. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  292. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  293. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  294. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  295. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  296. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  297. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  298. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  299. package/dist/apps/shared/keyboard/format.js +74 -0
  300. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  301. package/dist/apps/shared/keyboard/keymap.js +84 -0
  302. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  303. package/dist/apps/shared/keyboard/match.js +31 -0
  304. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  305. package/dist/apps/shared/keyboard/parse.js +107 -0
  306. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  307. package/dist/apps/shared/keyboard/platform.js +22 -0
  308. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  309. package/dist/apps/shared/keyboard/registry.js +33 -0
  310. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  311. package/dist/apps/shared/keyboard/targets.js +67 -0
  312. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  313. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  314. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  315. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  316. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  317. package/dist/apps/shared/mail/folderCounts.js +218 -0
  318. package/dist/apps/shared/mail/folderTree.d.ts +55 -0
  319. package/dist/apps/shared/mail/folderTree.js +116 -0
  320. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  321. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  322. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  323. package/dist/apps/shared/mail/messageReadState.js +63 -0
  324. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  325. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  326. package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
  327. package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
  328. package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
  329. package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
  330. package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
  331. package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
  332. package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
  333. package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
  334. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
  335. package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
  336. package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
  337. package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
  338. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
  339. package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
  340. package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
  341. package/dist/apps/shared/mail/outbox/sendState.js +8 -0
  342. package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
  343. package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
  344. package/dist/apps/shared/mail/useMailConnection.d.ts +56 -0
  345. package/dist/apps/shared/mail/useMailConnection.js +138 -0
  346. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +29 -9
  347. package/dist/apps/shared/mail/useMailLiveUpdates.js +60 -32
  348. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  349. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  350. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +35 -0
  351. package/dist/apps/shared/mail/useNewMailNotifications.js +130 -0
  352. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  353. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  354. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  355. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  356. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  357. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  358. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  359. package/dist/apps/shared/navigation/frameContext.js +24 -0
  360. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  361. package/dist/apps/shared/navigation/idle.js +43 -0
  362. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  363. package/dist/apps/shared/navigation/routerContext.js +56 -0
  364. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  365. package/dist/apps/shared/navigation/routes.js +37 -0
  366. package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
  367. package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
  368. package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
  369. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
  370. package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
  371. package/dist/apps/shared/notifications/apiErrors.js +86 -0
  372. package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
  373. package/dist/apps/shared/notifications/headerOffset.js +33 -0
  374. package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
  375. package/dist/apps/shared/notifications/pushStatus.js +54 -0
  376. package/dist/apps/shared/notifications/store.d.ts +136 -0
  377. package/dist/apps/shared/notifications/store.js +416 -0
  378. package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
  379. package/dist/apps/shared/notifications/systemErrors.js +58 -0
  380. package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
  381. package/dist/apps/shared/notifications/useNotifications.js +18 -0
  382. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  383. package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
  384. package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
  385. package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
  386. package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
  387. package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
  388. package/dist/apps/shared/signing/enrollmentView.js +214 -0
  389. package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
  390. package/dist/apps/shared/signing/signingInfo.js +48 -0
  391. package/dist/apps/shared/signing/useNow.d.ts +2 -0
  392. package/dist/apps/shared/signing/useNow.js +18 -0
  393. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
  394. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
  395. package/dist/apps/shared/styles/app.css +108 -10
  396. package/dist/apps/www/_layout.d.ts +5 -1
  397. package/dist/apps/www/_layout.js +3 -2
  398. package/dist/apps/www/_routedPage.d.ts +12 -0
  399. package/dist/apps/www/_routedPage.js +19 -0
  400. package/dist/apps/www/_routes.d.ts +11 -0
  401. package/dist/apps/www/_routes.js +30 -0
  402. package/dist/apps/www/calendar/index.d.ts +2 -2
  403. package/dist/apps/www/calendar/index.js +41 -11
  404. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  405. package/dist/apps/www/contacts/[uid].js +11 -9
  406. package/dist/apps/www/contacts/index.d.ts +2 -2
  407. package/dist/apps/www/contacts/index.js +30 -24
  408. package/dist/apps/www/index.d.ts +2 -2
  409. package/dist/apps/www/index.js +332 -48
  410. package/dist/apps/www/messages/[uid].d.ts +3 -7
  411. package/dist/apps/www/messages/[uid].js +10 -5
  412. package/dist/apps/www/settings/appearance/index.d.ts +4 -0
  413. package/dist/apps/www/settings/appearance/index.js +13 -0
  414. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  415. package/dist/apps/www/settings/auto-reply/index.js +6 -7
  416. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  417. package/dist/apps/www/settings/encryption/index.js +106 -96
  418. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  419. package/dist/apps/www/settings/filters/[uid].js +6 -2
  420. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  421. package/dist/apps/www/settings/filters/index.js +3 -1
  422. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  423. package/dist/apps/www/settings/filters/new/index.js +9 -3
  424. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  425. package/dist/apps/www/settings/labels/index.js +3 -1
  426. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  427. package/dist/apps/www/settings/privacy/index.js +8 -9
  428. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  429. package/dist/apps/www/settings/read-receipts/index.js +6 -7
  430. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  431. package/dist/apps/www/settings/sharing/index.js +22 -34
  432. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  433. package/dist/apps/www/settings/signatures/[uid].js +6 -2
  434. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  435. package/dist/apps/www/settings/signatures/index.js +3 -1
  436. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  437. package/dist/apps/www/settings/signatures/new/index.js +9 -4
  438. package/dist/apps/www/tasks/index.d.ts +2 -2
  439. package/dist/apps/www/tasks/index.js +22 -16
  440. package/package.json +2 -2
  441. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
  442. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
  443. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
@@ -6,65 +6,49 @@ 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
- import { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
20
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";
22
+ import { useCompose } from "./ComposeContext.js";
23
+ import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
24
+ import { SHORTCUTS } from "../../../keyboard/keymap.js";
25
+ import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
26
+ import { useShortcut } from "../../../keyboard/useShortcut.js";
21
27
  import { isSigningOut, registerComposeFlush } from "./composeFlushRegistry.js";
28
+ import { markComposePhase } from "./composePerf.js";
22
29
  import RecipientInput from "./RecipientInput.js";
23
30
  import { parseRecipientList } from "./recipients.js";
24
31
  import RichTextEditor from "./RichTextEditor.js";
25
32
  import ScheduleSendPicker from "./ScheduleSendPicker.js";
26
- import SendFailureAlert from "./SendFailureAlert.js";
27
33
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
28
34
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
29
35
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
30
36
  /** Debounce between the last edit and the draft autosave. */
31
37
  export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
32
- /** Automatic retries of a failed mailbox/encryption-policy load; after the last one only "Retry" tries again. */
33
- export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000];
34
- /** Signed/encrypted bodies are built client-side from the editor's HTML verbatim, so an inline image
35
- * (an uploaded attachment previewed via its content URL, or a `cid:` reference) would never be carried -
36
- * only `assembleDraft()`'s server-side plaintext path rewrites those into real MIME parts. */
37
- const INLINE_IMAGE_PATTERN = /<img\b[^>]*\bsrc\s*=\s*["']?(?:cid:|[^"'\s>]*\/mail\/attachments\/[^"'\s>]+\/content)/i;
38
- 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.";
39
- 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.";
40
- 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.";
41
- const BCC_ENCRYPTED_MESSAGE = "Bcc recipients can't be used with encrypted messages. Remove Bcc recipients or turn off encryption.";
42
- 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];
43
41
  /** Thrown by `deleteDraft()` when the server copy is no longer this window's draft (sent or scheduled elsewhere). */
44
42
  const NO_LONGER_A_DRAFT_MESSAGE = "This message was already sent or scheduled from another window, so it wasn't deleted.";
45
- /** The server refuses signed/encrypted mail whose From display name differs from its own safe form, which drops a
46
- * name containing `@` (or a look-alike) or a line break. */
47
- const UNSAFE_DISPLAY_NAME_PATTERN = /[@@﹫\r\n]/;
48
43
  /** Whether `fresh` (a re-read of a draft) is still an unsent draft in `folderUid`: never delete a message another
49
44
  * window has since sent, scheduled, or started sending. */
50
45
  function isStillDraft(fresh, folderUid) {
51
46
  return fresh.folderUid === folderUid && !fresh.scheduledSendTime && !fresh.scheduledSendLeaseExpiresAt && !fresh.scheduledSendRelayedAt;
52
47
  }
53
- /** Any tier of `policy` auto-encrypts - only then can a message with recipients still to be added turn out encrypted
54
- * without the user asking. */
55
- function policyCanAutoEncrypt(policy) {
56
- return [policy.encryptSameOrg, policy.encryptFederated, policy.encryptExternal].includes("automatic");
57
- }
58
48
  const SAVE_STATUS_LABEL = { idle: "", saving: "Saving…", saved: "Draft saved", error: "Couldn't save draft" };
59
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`. */
60
51
  const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so closing this window discards what you've written.";
61
- 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.";
62
- 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.";
63
- 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.";
64
- const SEND_FAILED_TITLE = "This message wasn't sent";
65
- function sendFailedCloseMessage(reason) {
66
- return `Sending it failed (${reason.replace(/\.$/, "")}). Closing keeps it as a draft, unsent. Keep editing to try again, or discard it.`;
67
- }
68
52
  const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
69
53
  const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
70
54
  const MIN_WIDTH = 320;
@@ -80,8 +64,9 @@ const VIEWPORT_MARGIN = 24;
80
64
  function parseAddresses(value) {
81
65
  return parseRecipientList(value);
82
66
  }
83
- function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
84
- return (_jsx("button", { type: "button", "aria-label": label, title: label, onClick: onClick, disabled: disabled, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(Icon, { size: 14 }) }));
67
+ function HeaderButton({ label, onClick, icon: Icon, disabled, shortcut, }) {
68
+ const env = useKeyEnvironment();
69
+ return (_jsx("button", { type: "button", "aria-label": label, title: shortcut ? withHint(label, shortcut, env) : label, "aria-keyshortcuts": shortcut ? ariaKeyShortcuts(shortcut, env) : undefined, onClick: onClick, disabled: disabled, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(Icon, { size: 14 }) }));
85
70
  }
86
71
  /**
87
72
  * A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
@@ -98,7 +83,15 @@ function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
98
83
  * once.
99
84
  */
100
85
  export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS, }) {
101
- const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized } = session;
86
+ const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late, resume } = session;
87
+ // What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
88
+ // only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
89
+ const baselineTo = late?.to ?? initialTo;
90
+ const baselineCc = late?.cc ?? initialCc;
91
+ useEffect(() => {
92
+ markComposePhase(id, "shell");
93
+ markComposePhase(id, "chunk");
94
+ }, []);
102
95
  // The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
103
96
  // compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
104
97
  // Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
@@ -107,35 +100,40 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
107
100
  const [mailboxes, setMailboxes] = useState([]);
108
101
  // Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
109
102
  // Drafts folder - so once anything has been uploaded the sender can no longer be switched.
110
- const [hasUploads, setHasUploads] = useState(false);
103
+ const [hasUploads, setHasUploads] = useState(!!resume && resume.attachments.length > 0);
111
104
  const isMobile = useIsMobile();
112
105
  const { requestUnlock } = useUnlockPrompt();
106
+ const { openCompose } = useCompose();
107
+ const keyEnv = useKeyEnvironment();
113
108
  // Bumped after a successful on-demand unlock to force a re-render - `getUnlockedKeys()` below is a
114
109
  // plain read from a module-level store, not React state, so nothing else would pick up the change.
115
110
  const [, setUnlockRefresh] = useState(0);
116
111
  const windowRef = useRef(null);
117
112
  const [draftsFolderUid, setDraftsFolderUid] = useState();
118
113
  const [folderError, setFolderError] = useState(null);
119
- 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);
120
117
  // Drafts replaced by a From switch, awaiting deletion until their replacement has actually been created.
121
118
  const supersededDraftsRef = useRef([]);
122
119
  const [draftError, setDraftError] = useState(null);
123
120
  const [expanded, setExpanded] = useState(false);
124
121
  const [manualSize, setManualSize] = useState(null);
125
- const [to, setTo] = useState(initialTo ?? "");
126
- const [cc, setCc] = useState(initialCc ?? "");
127
- const [bcc, setBcc] = useState("");
128
- const [showCcBcc, setShowCcBcc] = useState(!!initialCc);
129
- const [subject, setSubject] = useState(initialSubject ?? "");
130
- const [html, setHtml] = useState("");
131
- const [contentReady, setContentReady] = useState(false);
132
- 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 ?? []);
133
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`).
134
133
  const [sendError, setSendError] = useState(null);
135
- // The technical facts behind `sendError` (per-recipient SMTP results, a transport error), when the server gave any.
136
- const [sendDetails, setSendDetails] = useState([]);
137
- const [sending, setSending] = useState(false);
138
- 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);
139
137
  const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
140
138
  const scheduleButtonRef = useRef(null);
141
139
  const [mailbox, setMailbox] = useState(null);
@@ -144,11 +142,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
144
142
  // Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
145
143
  // `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
146
144
  // still turn it back on; this only changes the default.
147
- const [signEnabled, setSignEnabled] = useState(!suppressSigning);
145
+ const [signEnabled, setSignEnabled] = useState(resume ? resume.signEnabled : !suppressSigning);
148
146
  // A reply to or forward of an encrypted message starts (and, after a From switch, restarts) with encryption
149
147
  // requested - see `OpenComposeInput.encrypt`.
150
- const [encryptRequested, setEncryptRequested] = useState(!!initialEncrypt);
151
- const [encryptionBlocked, setEncryptionBlocked] = useState(null);
148
+ const [encryptRequested, setEncryptRequested] = useState(resume ? resume.encryptRequested : !!initialEncrypt);
152
149
  // Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
153
150
  // recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
154
151
  // just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
@@ -158,16 +155,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
158
155
  // look one up twice), and a counter bumped by a From switch so a lookup for the previous sender is dropped.
159
156
  const lookupsInFlightRef = useRef(new Set());
160
157
  const discoveryGenerationRef = useRef(0);
161
- // A failed lookup stores no status (it would read as "no key" and let a message that must be encrypted autosave
162
- // or send as plaintext): it's retried with the same backoff as the mailbox/policy load, and once those retries
163
- // 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.
164
160
  const lookupFailuresRef = useRef(new Map());
165
161
  const lookupRetryTimersRef = useRef(new Set());
166
162
  const [lookupRetryToken, setLookupRetryToken] = useState(0);
167
- const [exhaustedLookups, setExhaustedLookups] = useState({});
168
- // A send refused because signing/encryption was wanted but can't happen right now (keys locked, the
169
- // encryption policy/mailbox couldn't be loaded, or Bcc recipients on an encrypted message) - never a
170
- // 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.
171
166
  const [securityBlock, setSecurityBlock] = useState(null);
172
167
  // The schedule time of the send that got blocked (undefined for an immediate send), so the banners'
173
168
  // override buttons replay the same kind of send the user originally asked for.
@@ -188,8 +183,11 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
188
183
  const lastSavedRef = useRef(null);
189
184
  /** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
190
185
  const pendingSaveRef = useRef(false);
191
- /** Mirrors `sending` for the page-leave/sign-out flushes, which run outside React's render cycle. */
192
- 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([]);
193
191
  /** The latest save request, if any - each save is chained after the one before it. Always settles (never
194
192
  * rejects), to the saved message, or `undefined` when that save failed. */
195
193
  const saveInFlightRef = useRef(Promise.resolve(undefined));
@@ -197,13 +195,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
197
195
  const finishedRef = useRef(false);
198
196
  // A mailbox with no keys enrolled yet just means sign/encrypt stay unavailable for this compose session -
199
197
  // encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
200
- // `cryptoContextReady` (below) gates Send/Send-later until both calls have settled either way, so a
201
- // send that happens to race this fetch can't silently skip encryption the spec says should apply.
202
- // A failed load is retried with backoff (`cryptoRetryDelaysMs`), then on demand ("Retry"): until both have
203
- // loaded, whether this message must be encrypted is unknown, so it isn't autosaved (see `autosaveSuppressed`).
204
- const [cryptoContextReady, setCryptoContextReady] = useState(false);
205
- const [cryptoLoadFailed, setCryptoLoadFailed] = useState(false);
206
- 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.
207
202
  useEffect(() => {
208
203
  let cancelled = false;
209
204
  listMailboxes({ limit: 100 })
@@ -238,8 +233,6 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
238
233
  let mailboxLoaded = mailbox?.uid === mailboxUid;
239
234
  let policyLoaded = !!encryptionPolicy;
240
235
  let retries = 0;
241
- setCryptoContextReady(false);
242
- setCryptoLoadFailed(false);
243
236
  async function load() {
244
237
  await Promise.all([
245
238
  mailboxLoaded ||
@@ -257,13 +250,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
257
250
  }
258
251
  }, () => undefined),
259
252
  ]);
260
- if (cancelled) {
261
- return;
262
- }
263
- const failed = !mailboxLoaded || !policyLoaded;
264
- setCryptoContextReady(true);
265
- setCryptoLoadFailed(failed);
266
- if (failed && retries < cryptoRetryDelaysMs.length) {
253
+ if (!cancelled && (!mailboxLoaded || !policyLoaded) && retries < cryptoRetryDelaysMs.length) {
267
254
  timer = setTimeout(() => void load(), cryptoRetryDelaysMs[retries++]);
268
255
  }
269
256
  }
@@ -272,7 +259,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
272
259
  cancelled = true;
273
260
  clearTimeout(timer);
274
261
  };
275
- }, [mailboxUid, cryptoRetryToken]);
262
+ }, [mailboxUid]);
276
263
  useEffect(() => {
277
264
  if (!mailboxUid) {
278
265
  return;
@@ -299,14 +286,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
299
286
  * Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
300
287
  * caching the result so the small per-recipient indicator below the recipient fields can update
301
288
  * without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
302
- * 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
303
290
  * best-effort UI hint, not something a stale value here should be trusted to skip at send time).
304
291
  * A mailbox/policy fetch that hasn't resolved yet just leaves that address with no indicator - the effect
305
292
  * below runs it again for every current recipient once both have loaded. A failed lookup leaves it without
306
- * 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).
307
294
  *
308
- * The statuses also drive autosave: a recipient without one yet (lookup pending, failed or never run) or a
309
- * 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.
310
297
  */
311
298
  function checkRecipientDiscovery(addresses) {
312
299
  if (!mailbox || !encryptionPolicy) {
@@ -335,12 +322,11 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
335
322
  });
336
323
  }
337
324
  }
338
- /** 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). */
339
326
  function noteLookupFailed(address) {
340
327
  const failures = (lookupFailuresRef.current.get(address) ?? 0) + 1;
341
328
  lookupFailuresRef.current.set(address, failures);
342
329
  if (failures > cryptoRetryDelaysMs.length) {
343
- setExhaustedLookups((prev) => ({ ...prev, [address]: true }));
344
330
  return;
345
331
  }
346
332
  const timer = setTimeout(() => {
@@ -359,7 +345,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
359
345
  useEffect(() => {
360
346
  checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
361
347
  }, [mailbox, encryptionPolicy, lookupRetryToken]);
362
- // 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)
363
349
  // plus any quoted original message (laid out by `buildComposeBodyHtml()`: an empty first paragraph for the
364
350
  // caret, then the signature, then the quote), before `RichTextEditor` ever mounts (gated by `contentReady`
365
351
  // below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
@@ -368,26 +354,41 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
368
354
  // non-blocking fetch in this codebase (e.g. `ConversationList`'s own child-message fetch) — no
369
355
  // signature is a completely legitimate outcome, so this falls back to just the quoted content
370
356
  // (if any) rather than surfacing an error over what's a cosmetic nicety.
357
+ //
358
+ // A reply or forward opens before its quoted original has been fetched (`quotePending`, see
359
+ // `OpenComposeInput.pending`). The body is seeded as soon as the signature is here, without the quote, so the editor is usable
360
+ // at once however slow the original is to come; the quote is added at the end of the editor's document when it arrives
361
+ // (`appendHtml` below) - the same place, under the signature, that a body seeded with it would have had it.
362
+ const [defaultSignature, setDefaultSignature] = useState();
371
363
  useEffect(() => {
372
- // Seeds the body once, from whichever mailbox is the sender when compose opens - switching From
373
- // later must not overwrite what the user has already written (the editor itself never re-syncs).
374
- if (!mailboxUid || contentReady) {
364
+ // Fetched once, for whichever mailbox is the sender when compose opens - switching From later must not
365
+ // overwrite what the user has already written (the editor itself never re-syncs).
366
+ if (!mailboxUid || contentReady || defaultSignature) {
375
367
  return;
376
368
  }
377
369
  listMailSignatures(mailboxUid)
378
370
  .then((signatures) => {
379
371
  const signature = signatures.find((s) => signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward);
380
- const seeded = buildComposeBodyHtml(signature?.contentHtml, initialQuotedHtml);
381
- setHtml(seeded);
382
- setSeededHtml(seeded);
383
- })
384
- .catch(() => {
385
- const seeded = buildComposeBodyHtml(undefined, initialQuotedHtml);
386
- setHtml(seeded);
387
- setSeededHtml(seeded);
372
+ setDefaultSignature({ html: signature?.contentHtml });
388
373
  })
389
- .finally(() => setContentReady(true));
390
- }, [mailboxUid, signatureContext, initialQuotedHtml]);
374
+ .catch(() => setDefaultSignature({}));
375
+ }, [mailboxUid, signatureContext]);
376
+ const [seededWithoutQuote, setSeededWithoutQuote] = useState(false);
377
+ useEffect(() => {
378
+ if (contentReady || !defaultSignature) {
379
+ return;
380
+ }
381
+ const seeded = buildComposeBodyHtml(defaultSignature.html, quotePending ? undefined : initialQuotedHtml);
382
+ setHtml(seeded);
383
+ setSeededHtml(seeded);
384
+ setSeededWithoutQuote(!!quotePending);
385
+ setContentReady(true);
386
+ markComposePhase(id, "body");
387
+ }, [defaultSignature, quotePending, initialQuotedHtml]);
388
+ /** What the editor adds to the end of its document once the quote a reply was opened without has arrived. */
389
+ const lateQuoteHtml = seededWithoutQuote && !quotePending && initialQuotedHtml && defaultSignature
390
+ ? buildComposeBodyHtml(defaultSignature.html, initialQuotedHtml).slice(buildComposeBodyHtml(defaultSignature.html) === "" ? "<p></p>".length : buildComposeBodyHtml(defaultSignature.html).length)
391
+ : undefined;
391
392
  useEffect(() => {
392
393
  if (!mailboxUid || !draftsFolderUid || draft) {
393
394
  return;
@@ -405,6 +406,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
405
406
  return;
406
407
  }
407
408
  setDraft(created);
409
+ markComposePhase(id, "draft");
408
410
  // Only now that the replacement exists is it safe to discard the draft(s) a From switch
409
411
  // superseded - if creating this one had failed, the earlier draft is still there.
410
412
  for (const superseded of supersededDraftsRef.current.splice(0)) {
@@ -491,9 +493,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
491
493
  discoveryGenerationRef.current += 1;
492
494
  lookupsInFlightRef.current = new Set();
493
495
  lookupFailuresRef.current = new Map();
494
- setExhaustedLookups({});
495
496
  setEncryptRequested(!!initialEncrypt);
496
- setEncryptionBlocked(null);
497
497
  setSecurityBlock(null);
498
498
  offeredCryptoRef.current = { sign: false, encrypt: false };
499
499
  setFromMailboxUid(nextMailboxUid);
@@ -549,6 +549,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
549
549
  setAttachError("Please wait for the draft to finish loading before inserting an image.");
550
550
  return null;
551
551
  }
552
+ uploadStarted();
552
553
  try {
553
554
  const attachment = await uploadAttachment(draft.uid, file);
554
555
  setHasUploads(true);
@@ -559,6 +560,21 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
559
560
  setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
560
561
  return null;
561
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
+ }
562
578
  }
563
579
  /** Uploading an attachment bumps the draft's version server-side (it records `hasAttachments`), so a later
564
580
  * Discard would otherwise send a stale `?version=`. Best-effort: Discard also retries with the server's
@@ -581,198 +597,110 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
581
597
  const files = Array.from(e.target.files ?? []);
582
598
  e.target.value = "";
583
599
  setAttachError(null);
584
- for (const file of files) {
585
- try {
586
- const attachment = await uploadAttachment(draft.uid, file);
587
- setHasUploads(true);
588
- setAttachments((prev) => [...prev, attachment]);
589
- void refreshDraftVersion(draft.uid);
590
- }
591
- catch (err) {
592
- setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
593
- }
594
- }
595
- }
596
- /**
597
- * Builds this draft's final body — plaintext, signed-only, or signed+encrypted per
598
- * `specs/end-to-end_encryption.md` — then stores it via `assembleDraft()`/`assembleDraftRaw()`.
599
- * Shared by immediate and scheduled sends (and the banners' override buttons rendered below, via
600
- * `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
601
- *
602
- * Returns `"blocked"` (never throws for this case) whenever the message can't go out the way the user
603
- * asked for — never a silent downgrade to plaintext:
604
- * encryption was requested (or auto-applies) but not every recipient can currently be encrypted to -
605
- * the spec's "Multiple Recipients" all-or-nothing rule (sets `encryptionBlocked`); signing/encryption was
606
- * on offer this session but the keys have since locked, or the mailbox/encryption policy needed to decide
607
- * couldn't be loaded (sets `securityBlock`, and re-prompts the unlock dialog when the keys are merely
608
- * locked); or the message would be encrypted but has Bcc recipients - every recipient's certificate goes
609
- * into one shared envelope, which would disclose the Bcc list to every other recipient, and the send API
610
- * has no per-recipient-copy submission to avoid that (sets `securityBlock`).
611
- * Callers must stop (not send) when they get this back. `forcePlaintext` (the user's explicit choice
612
- * from one of those banners) skips every one of these checks and never encrypts; signing still
613
- * happens if it's currently possible.
614
- *
615
- * Discovery (`lookupKeys()`) and the resulting sign/encrypt decision only run at this, the final
616
- * pre-send step — `checkRecipientDiscovery()`'s on-blur badges are a best-effort hint only.
617
- */
618
- async function assembleForSend(toRecipients, ccRecipients, bccRecipients, forcePlaintext) {
619
- // Keys are read at the moment they're needed, and again after any await: they can lock (idle
620
- // timeout, sign-out elsewhere) mid-send, and a destroyed `UnlockedKeys` object must count as locked.
621
- // Only reachable once a draft exists, which itself requires `mailboxUid` to have resolved.
622
- function readKeys() {
623
- const stored = getUnlockedKeys(mailboxUid);
624
- const unlocked = stored?.destroyed ? undefined : stored;
625
- return {
626
- unlocked,
627
- // `mailbox` is required to build protected headers (own From address) whenever signing or
628
- // encrypting - not just guarded on the encryption branch below, since a signed-only message
629
- // needs it too.
630
- canSign: signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer,
631
- canEncryptSelf: !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer,
632
- };
633
- }
634
- let keys = readKeys();
635
- const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
636
- // The Sign toggle was shown (and is still checked), but signing can't actually happen any more.
637
- const signingLost = () => !forcePlaintext && signEnabled && offeredCryptoRef.current.sign && !keys.canSign;
638
- if (signingLost()) {
639
- return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
640
- }
641
- if (!forcePlaintext && !mailbox) {
642
- // The mailbox's enrolled keys are what say whether this message could be encrypted at all - with keys
643
- // that aren't unlocked in this session, a failed mailbox load would otherwise skip encryption silently.
644
- return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
645
- }
646
- let wantEncrypt = false;
647
- let recipientCertDers = [];
648
- // Entered whenever this message could be encrypted at all - including a mailbox with an enrolled
649
- // encryption key that this session never unlocked: policy may still auto-encrypt, and that must block
650
- // (and prompt to unlock) rather than quietly send plaintext.
651
- if (!forcePlaintext && (keys.canEncryptSelf || offeredCryptoRef.current.encrypt || hasEnrolledEncryptionKey)) {
652
- if (!encryptionPolicy) {
653
- // Without it, there's no telling whether policy auto-encrypts this message.
654
- return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
655
- }
656
- // Reachable only with `mailbox` loaded (checked above).
657
- const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
658
- const lookups = await Promise.all(allRecipients.map((r) => lookupKeys(mailboxUid, r.address).then((lookup) => ({ lookup }), () => null)));
659
- keys = readKeys();
660
- if (signingLost()) {
661
- return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
662
- }
663
- // A failed lookup isn't "no key": treating it as one could send plaintext that policy would encrypt.
664
- if (lookups.some((result) => !result) && (encryptRequested || policyCanAutoEncrypt(encryptionPolicy))) {
665
- return blockSend(LOOKUP_UNAVAILABLE_MESSAGE, "Send without encryption", false);
666
- }
667
- const statuses = allRecipients.map((r, i) => resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]?.lookup));
668
- const decision = decideMessageEncryption(statuses);
669
- wantEncrypt = encryptRequested || decision.autoEncrypt;
670
- if (wantEncrypt) {
671
- if (!keys.canEncryptSelf) {
672
- return blockSend(KEYS_LOCKED_ENCRYPT_MESSAGE, "Send without encryption", !keys.unlocked);
673
- }
674
- if (!decision.canEncryptAll) {
675
- setEncryptionBlocked(decision.blockedRecipients);
676
- 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);
677
608
  }
678
- if (bccRecipients.length > 0) {
679
- return blockSend(BCC_ENCRYPTED_MESSAGE, "Send without encryption", false);
609
+ catch (err) {
610
+ setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
680
611
  }
681
- recipientCertDers = [keys.unlocked.encryptionCertDer, ...statuses.map((s) => fromBase64(s.encryptCert.publicKey))];
682
612
  }
683
613
  }
684
- const { canSign } = keys;
685
- if ((wantEncrypt || canSign) && attachments.length > 0) {
686
- // Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
687
- // clearer explanation than that route's generic 400 rather than via a round trip.
688
- throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
689
- }
690
- if ((wantEncrypt || canSign) && INLINE_IMAGE_PATTERN.test(html)) {
691
- throw new ApiRequestError("A signed or encrypted message cannot include inline images yet - remove them before sending.", 400);
692
- }
693
- if (!wantEncrypt && !canSign) {
694
- // See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
695
- // gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
696
- // client-side pass is done here either.
697
- return assembleDraft(draft.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject, html });
698
- }
699
- const domain = mailbox.primarySmtpAddress.split("@")[1] ?? "localhost";
700
- const displayName = mailbox.displayName && !UNSAFE_DISPLAY_NAME_PATTERN.test(mailbox.displayName) ? mailbox.displayName : "";
701
- const protectedHeaders = {
702
- from: displayName
703
- ? `"${displayName.replace(/"/g, '\\"')}" <${mailbox.primarySmtpAddress}>`
704
- : mailbox.primarySmtpAddress,
705
- to: toRecipients.map((r) => r.address).join(", "),
706
- cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
707
- date: new Date().toUTCString(),
708
- subject,
709
- messageId: `<${crypto.randomUUID()}@${domain}>`,
710
- };
711
- const bodyContentType = 'text/html; charset="utf-8"';
712
- const signing = canSign ? { certDer: keys.unlocked.signingCertDer, privateKey: keys.unlocked.signingPrivateKey } : undefined;
713
- const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
714
- const mimePart = wantEncrypt
715
- ? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
716
- : await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing.certDer, signing.privateKey);
717
- const rawMime = assembleOutboundMime(outerHeaders, mimePart);
718
- return assembleDraftRaw(draft.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
719
- }
720
- /** Records why a send was refused (see `assembleForSend()`), re-prompting the unlock dialog when the
721
- * cause is keys that are enrolled but locked. */
722
- function blockSend(message, overrideLabel, keysLocked) {
723
- setSecurityBlock({ message, overrideLabel });
724
- if (keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
725
- void handleUnlockForCrypto();
614
+ finally {
615
+ uploadFinished();
726
616
  }
727
- return "blocked";
728
617
  }
729
618
  /**
730
- * Sends (or, given `scheduledSendTimeIso`, schedules) the draft. Same reasoning as `handleFilesSelected`
731
- * above: Send and the "Send later" caret are both `disabled` until `draft` resolves, so this is never
732
- * reachable with a null `draft`. A scheduled send assembles the draft first (same as an immediate send),
733
- * then sets `scheduledSendTime` on the *freshly assembled* copy before calling `sendMessage()` — `send()`
734
- * itself is what reads the field and defers relay into Outbox instead of sending immediately (see
735
- * `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`). A blocked send remembers its schedule
736
- * 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.
737
631
  */
738
632
  async function submit(forcePlaintext, scheduledSendTimeIso) {
739
- const toRecipients = parseAddresses(to);
740
- if (toRecipients.length === 0) {
633
+ if (submittingRef.current || finishedRef.current) {
634
+ return;
635
+ }
636
+ if (parseAddresses(latestRef.current.to).length === 0) {
741
637
  setSendError("At least one recipient is required.");
742
638
  return;
743
639
  }
744
- setSending(true);
745
- sendingRef.current = true;
640
+ submittingRef.current = true;
746
641
  setSendError(null);
747
- setSendDetails([]);
748
642
  setSecurityBlock(null);
749
- setEncryptionBlocked(null);
750
- pendingSaveRef.current = false;
751
643
  try {
752
- // An autosave already on the wire must never land after (and overwrite) the send's own assembly.
753
- await saveInFlightRef.current;
754
- const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), forcePlaintext);
755
- 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") {
756
685
  setBlockedScheduleIso(scheduledSendTimeIso);
686
+ setSecurityBlock(early.block);
687
+ if (early.block.keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
688
+ void handleUnlockForCrypto();
689
+ }
757
690
  return;
758
691
  }
759
- // The draft now holds exactly this content (possibly signed/encrypted) - don't autosave a
760
- // plaintext copy over it if the rest of the send fails.
761
- lastSavedRef.current = `${assembled.uid}:${contentKey}`;
762
- const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
763
- await sendMessage(withReceipt.uid, scheduledSendTimeIso ? { scheduledSendTime: scheduledSendTimeIso } : undefined);
692
+ pendingSaveRef.current = false;
764
693
  finishedRef.current = true;
694
+ if (!startSend(request)) {
695
+ finishedRef.current = false;
696
+ setSendError("This message is already being sent.");
697
+ return;
698
+ }
765
699
  onClose();
766
700
  }
767
- catch (err) {
768
- // The window stays open with the draft exactly as it was; nothing here closes, discards or saves over it.
769
- const failure = describeSendFailure(err, scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.");
770
- setSendError(failure.message);
771
- setSendDetails(failure.lines);
772
- }
773
701
  finally {
774
- sendingRef.current = false;
775
- setSending(false);
702
+ submittingRef.current = false;
703
+ setWaitingForUploads(false);
776
704
  }
777
705
  }
778
706
  function handleScheduleSend(scheduledSendTimeIso) {
@@ -782,7 +710,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
782
710
  async function handleUnlockForCrypto() {
783
711
  try {
784
712
  // Only reachable when hasEnrolledSigningKey/hasEnrolledEncryptionKey found a real enrolled key
785
- // 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.
786
714
  await requestUnlock(mailbox.uid, mailbox.keys);
787
715
  setUnlockRefresh((n) => n + 1);
788
716
  }
@@ -797,7 +725,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
797
725
  * `latestRef` only once it actually runs, so it saves what was last typed (skipping the request when the
798
726
  * save before it already stored exactly that). Whether a save may happen at all is re-checked at that same
799
727
  * moment: while it waited, the message may have become one that must not be stored as plaintext (Encrypt
800
- * 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
801
729
  * resolves `{ skipped: true }` without a request (`message` then being the previous save's result). Never
802
730
  * rejects; `message` is `undefined` when the save failed.
803
731
  */
@@ -809,7 +737,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
809
737
  const outcome = (async () => {
810
738
  const before = await previous;
811
739
  const current = latestRef.current;
812
- if (current.autosaveSuppressed || sendingRef.current || finishedRef.current) {
740
+ if (current.autosaveSuppressed || finishedRef.current) {
813
741
  setSaveStatus("idle");
814
742
  // Still the newest copy the server holds, for the version a later delete needs.
815
743
  return { skipped: true, message: before };
@@ -928,39 +856,19 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
928
856
  }
929
857
  }
930
858
  /** "Close": keeps the draft (saving any unsaved edits first, and closing only once that save succeeded) -
931
- * unless there's nothing in it worth keeping, or it can't be saved because it's headed for encryption, or
932
- * whether it is can't be told yet (settings loading or unavailable, recipient lookups pending): those need
933
- * the same confirmation as a discard. A failed save keeps the window open with Discard / Keep editing.
934
- * Close and Discard are both disabled while a send is in progress: deleting the draft, or saving a
935
- * plaintext copy over the message the send just assembled, mid-send would lose or leak it. */
936
- 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() {
937
864
  if (!hasUserContent) {
938
865
  void discardNow();
939
866
  return;
940
867
  }
941
- if (sendError && !sendFailureAcknowledged) {
942
- // Closing would keep the draft and say nothing, which reads as if it had been sent.
943
- setClosePrompt({ title: SEND_FAILED_TITLE, message: sendFailedCloseMessage(sendError), closeAnyway: true });
944
- return;
945
- }
946
868
  if (encryptionDecided) {
947
869
  setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
948
870
  return;
949
871
  }
950
- if (cryptoCheckUnavailable) {
951
- setClosePrompt({ title: DISCARD_TITLE, message: CRYPTO_UNAVAILABLE_MESSAGE, retry: true });
952
- return;
953
- }
954
- if (awaitingRecipients) {
955
- setClosePrompt({ title: DISCARD_TITLE, message: NO_RECIPIENTS_CLOSE_MESSAGE });
956
- return;
957
- }
958
- if (encryptionUndetermined) {
959
- // A recipient typed but never blurred has no lookup running yet.
960
- checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
961
- setClosePrompt({ title: DISCARD_TITLE, message: CHECKING_CLOSE_MESSAGE });
962
- return;
963
- }
964
872
  if (!draft || !contentReady) {
965
873
  saveErrorRef.current = draftError ?? "the draft hasn't been created yet";
966
874
  showSaveFailedPrompt();
@@ -972,7 +880,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
972
880
  setClosing(false);
973
881
  if (outcome.skipped) {
974
882
  // It became a message that mustn't be saved as plaintext while waiting on an earlier save.
975
- setClosePrompt({ title: DISCARD_TITLE, message: latestRef.current.encryptionDecided ? ENCRYPTED_CLOSE_MESSAGE : CHECKING_CLOSE_MESSAGE });
883
+ setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
976
884
  return;
977
885
  }
978
886
  if (!outcome.message) {
@@ -983,13 +891,6 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
983
891
  finishedRef.current = true;
984
892
  onClose();
985
893
  }
986
- function retryCryptoContext() {
987
- setClosePrompt(null);
988
- setCryptoRetryToken((n) => n + 1);
989
- lookupFailuresRef.current = new Map();
990
- setExhaustedLookups({});
991
- setLookupRetryToken((n) => n + 1);
992
- }
993
894
  // A plain read from keySession.ts's module-level session store, not React state - see that module's
994
895
  // own doc comment. Cheap enough to read fresh on every render rather than caching in state.
995
896
  const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
@@ -1019,34 +920,85 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1019
920
  // Autosave. "User content" is anything beyond what compose opened with (prefilled recipients/subject,
1020
921
  // seeded signature/quote) - an untouched window has nothing worth saving or confirming a discard of.
1021
922
  const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
1022
- const baselineKey = JSON.stringify([initialTo ?? "", initialCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
923
+ const baselineKey = JSON.stringify([baselineTo ?? "", baselineCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
1023
924
  const hasUserContent = contentKey !== baselineKey || hasUploads;
1024
- // A message headed for encryption is never autosaved: the draft would store its plaintext server-side,
1025
- // defeating end-to-end encryption. Until it's known not to be, it isn't either: while the mailbox (whose
1026
- // enrolled keys say whether encryption is possible at all) or the encryption policy hasn't loaded, and -
1027
- // whenever this message could be encrypted (an unlocked or enrolled encryption key) - while the policy is
1028
- // missing or any current recipient's lookup is still pending (or failed, or hasn't run) - and while there are no
1029
- // 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.
1030
934
  const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
1031
935
  const currentStatuses = currentAddresses.map((address) => recipientStatuses[address]);
1032
- const encryptionPossible = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
1033
- /** Could be auto-encrypted, but has no recipients yet to decide by. */
1034
- const awaitingRecipients = encryptionPossible && !!encryptionPolicy && currentAddresses.length === 0 && policyCanAutoEncrypt(encryptionPolicy);
1035
- const encryptionUndetermined = !cryptoContextReady || !mailbox || awaitingRecipients || (encryptionPossible && (!encryptionPolicy || currentStatuses.some((s) => !s)));
1036
- /** Known to be headed for encryption: requested, or policy auto-encrypts it. */
1037
- const encryptionDecided = encryptRequested || (!encryptionUndetermined && encryptionPossible && decideMessageEncryption(currentStatuses).autoEncrypt);
1038
- const autosaveSuppressed = encryptionDecided || encryptionUndetermined;
1039
- /** Loading the mailbox/policy failed (retries may still be pending) and this message's encryption hinges on it, or
1040
- * a current recipient's key lookup kept failing. */
1041
- const cryptoCheckUnavailable = (cryptoContextReady && cryptoLoadFailed && (!mailbox || (encryptionPossible && !encryptionPolicy))) ||
1042
- (encryptionPossible && currentAddresses.some((address) => exhaustedLookups[address] && !recipientStatuses[address]));
1043
- const latestRef = useRef({ draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided });
1044
- 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]);
984
+ // Recipients a reply could only work out after this window opened (Reply All: whoever the original's headers name)
985
+ // replace the ones it opened with - but only in a field the user hasn't touched, never over what they typed.
986
+ useEffect(() => {
987
+ if (!late) {
988
+ return;
989
+ }
990
+ const current = latestRef.current;
991
+ if (late.to !== undefined && current.to === (initialTo ?? "")) {
992
+ setTo(late.to);
993
+ }
994
+ if (late.cc !== undefined && current.cc === (initialCc ?? "")) {
995
+ setCc(late.cc);
996
+ setShowCcBcc((shown) => shown || late.cc !== "");
997
+ }
998
+ }, [late]);
1045
999
  useEffect(() => {
1046
1000
  const due = !!draft &&
1047
1001
  contentReady &&
1048
- cryptoContextReady &&
1049
- !sending &&
1050
1002
  !autosaveSuppressed &&
1051
1003
  hasUserContent &&
1052
1004
  lastSavedRef.current !== `${draft.uid}:${contentKey}`;
@@ -1056,7 +1008,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1056
1008
  }
1057
1009
  const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
1058
1010
  return () => clearTimeout(timer);
1059
- }, [draft, contentReady, cryptoContextReady, sending, autosaveSuppressed, hasUserContent, contentKey]);
1011
+ }, [draft, contentReady, autosaveSuppressed, hasUserContent, contentKey]);
1060
1012
  // Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
1061
1013
  // right away rather than losing it.
1062
1014
  useEffect(() => () => {
@@ -1098,7 +1050,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1098
1050
  // out - that navigation must not be cancellable.
1099
1051
  useEffect(() => {
1100
1052
  function handleBeforeUnload(event) {
1101
- if (finishedRef.current || sendingRef.current || isSigningOut()) {
1053
+ if (finishedRef.current || isSigningOut()) {
1102
1054
  return;
1103
1055
  }
1104
1056
  const latest = latestRef.current;
@@ -1118,7 +1070,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1118
1070
  // Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
1119
1071
  // with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
1120
1072
  // window must not move the caret there again.
1121
- const isReplyOrForward = initialQuotedHtml !== undefined;
1073
+ const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending || !!resume;
1122
1074
  const fieldsFocusedRef = useRef(false);
1123
1075
  const bodyFocusedRef = useRef(false);
1124
1076
  const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
@@ -1127,7 +1079,16 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1127
1079
  // The editor serializes the seeded body its own way (see `RichTextEditor`'s `onInitialized`). While the body is
1128
1080
  // still exactly what compose seeded, that serialization becomes the baseline - so an untouched reply or signature
1129
1081
  // isn't mistaken for an edit (and autosaved) just because the editor was focused or clicked.
1082
+ /** The quote has been added: unless the reader has typed something already, that is still exactly what compose seeded. */
1083
+ function handleQuoteAppended(normalized) {
1084
+ const latest = latestRef.current;
1085
+ if (latest.html === latest.seededHtml) {
1086
+ setHtml(normalized);
1087
+ setSeededHtml(normalized);
1088
+ }
1089
+ }
1130
1090
  function handleEditorInitialized(normalized) {
1091
+ markComposePhase(id, "editor");
1131
1092
  const latest = latestRef.current;
1132
1093
  if (latest.html === latest.seededHtml && normalized !== latest.html) {
1133
1094
  setHtml(normalized);
@@ -1140,21 +1101,37 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1140
1101
  bodyFocusedRef.current || (bodyFocusedRef.current = contentReady);
1141
1102
  }
1142
1103
  }, [minimized, contentReady]);
1104
+ // Keyboard shortcuts, active while the focus is inside this window (`container`; the scope attribute on its root is what tells the
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 -
1106
+ // a shortcut pressed while its button would be disabled is consumed and does nothing.
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;
1110
+ useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
1111
+ useShortcut(SHORTCUTS.compose.saveDraft, () => {
1112
+ // Autosave's own conditions: never a draft that must not be stored as plaintext.
1113
+ if (draft && contentReady && !closing && !autosaveSuppressed && hasUserContent) {
1114
+ void saveDraftNow();
1115
+ }
1116
+ }, { container: windowRef });
1117
+ useShortcut(SHORTCUTS.compose.close, () => {
1118
+ if (!closing) {
1119
+ void handleClose();
1120
+ }
1121
+ }, { container: windowRef });
1122
+ useShortcut(SHORTCUTS.compose.create, () => openCompose({ mailboxUid }), { container: windowRef });
1143
1123
  const title = subject.trim() || "New Message";
1144
1124
  const titleId = `compose-title-${id}`;
1145
- 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: () => {
1146
- setClosePrompt(null);
1147
- void handleClose(true);
1148
- }, 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" })] })] }));
1149
1126
  if (minimized) {
1150
- 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] }));
1151
1128
  }
1152
- return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
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: [
1153
1130
  "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1154
1131
  isMobile
1155
1132
  ? "fixed inset-0 w-full h-full rounded-none border-0"
1156
1133
  : ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1157
- ].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 })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2", 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)) })] })] })), (() => {
1158
1135
  const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
1159
1136
  .map((r) => recipientStatuses[r.address])
1160
1137
  .filter((status) => !!status);
@@ -1162,5 +1139,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1162
1139
  return null;
1163
1140
  }
1164
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))) }));
1165
- })(), _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, { value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized })) }), 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), 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] }));
1166
1143
  }