@rapidmx/web-client 0.8.0 → 0.10.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 (247) hide show
  1. package/README.md +192 -88
  2. package/apps/shared/auth/accountUrl.ts +9 -0
  3. package/apps/shared/auth/adminAccess.ts +99 -0
  4. package/apps/shared/components/admin/elevation.ts +61 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
  6. package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
  7. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +87 -24
  8. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  9. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  10. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  11. package/apps/shared/components/layout/AppShell.tsx +411 -288
  12. package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
  13. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  14. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -126
  15. package/apps/shared/components/layout/UserMenu.tsx +303 -150
  16. package/apps/shared/components/mail/ConversationList.tsx +269 -253
  17. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  18. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  19. package/apps/shared/components/mail/MailAddress.tsx +88 -0
  20. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1383
  22. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  23. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  24. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  25. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1700
  26. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  27. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  28. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
  29. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  31. package/apps/shared/components/mail/layout/MailShell.tsx +476 -445
  32. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  34. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  35. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  36. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  37. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  38. package/apps/shared/keyboard/dispatch.ts +124 -0
  39. package/apps/shared/keyboard/format.ts +89 -0
  40. package/apps/shared/keyboard/keymap.ts +114 -0
  41. package/apps/shared/keyboard/match.ts +44 -0
  42. package/apps/shared/keyboard/parse.ts +136 -0
  43. package/apps/shared/keyboard/platform.ts +34 -0
  44. package/apps/shared/keyboard/registry.ts +65 -0
  45. package/apps/shared/keyboard/targets.ts +79 -0
  46. package/apps/shared/keyboard/useShortcut.ts +50 -0
  47. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  48. package/apps/shared/mail/folderCounts.ts +302 -0
  49. package/apps/shared/mail/listSnapshots.ts +87 -0
  50. package/apps/shared/mail/mergeFirstPage.ts +47 -0
  51. package/apps/shared/mail/messageReadState.ts +85 -0
  52. package/apps/shared/mail/newMailNotifications.ts +183 -0
  53. package/apps/shared/mail/useMailConnection.ts +150 -0
  54. package/apps/shared/mail/useMailLiveUpdates.ts +232 -0
  55. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  56. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  57. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  58. package/apps/shared/navigation/AppRouter.tsx +300 -0
  59. package/apps/shared/navigation/appHrefs.ts +23 -0
  60. package/apps/shared/navigation/frameContext.tsx +35 -0
  61. package/apps/shared/navigation/idle.ts +45 -0
  62. package/apps/shared/navigation/routerContext.tsx +83 -0
  63. package/apps/shared/navigation/routes.ts +72 -0
  64. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  65. package/apps/shared/styles/app.css +28 -10
  66. package/apps/www/_routedPage.tsx +24 -0
  67. package/apps/www/_routes.ts +35 -0
  68. package/apps/www/calendar/index.tsx +55 -19
  69. package/apps/www/contacts/[uid].tsx +112 -107
  70. package/apps/www/contacts/index.tsx +584 -567
  71. package/apps/www/index.tsx +2280 -1854
  72. package/apps/www/messages/[uid].tsx +106 -101
  73. package/apps/www/settings/auto-reply/index.tsx +4 -1
  74. package/apps/www/settings/encryption/index.tsx +1252 -1249
  75. package/apps/www/settings/filters/[uid].tsx +4 -1
  76. package/apps/www/settings/filters/index.tsx +102 -99
  77. package/apps/www/settings/filters/new/index.tsx +138 -133
  78. package/apps/www/settings/labels/index.tsx +204 -201
  79. package/apps/www/settings/privacy/index.tsx +4 -1
  80. package/apps/www/settings/read-receipts/index.tsx +4 -1
  81. package/apps/www/settings/sharing/index.tsx +277 -274
  82. package/apps/www/settings/signatures/[uid].tsx +170 -167
  83. package/apps/www/settings/signatures/index.tsx +88 -85
  84. package/apps/www/settings/signatures/new/index.tsx +134 -129
  85. package/apps/www/tasks/index.tsx +18 -2
  86. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  87. package/dist/apps/shared/auth/accountUrl.js +8 -0
  88. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  89. package/dist/apps/shared/auth/adminAccess.js +89 -0
  90. package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
  91. package/dist/apps/shared/components/admin/elevation.js +56 -0
  92. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
  93. package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
  94. package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
  95. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
  96. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  97. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  98. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  99. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  100. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  101. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  102. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  103. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  104. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
  105. package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
  106. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  107. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
  108. package/dist/apps/shared/components/layout/MailboxProvisioning.js +45 -8
  109. package/dist/apps/shared/components/layout/UserMenu.d.ts +28 -9
  110. package/dist/apps/shared/components/layout/UserMenu.js +87 -14
  111. package/dist/apps/shared/components/mail/ConversationList.js +9 -13
  112. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  113. package/dist/apps/shared/components/mail/ConversationThreadPane.js +53 -28
  114. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  115. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  116. package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
  117. package/dist/apps/shared/components/mail/MailAddress.js +39 -0
  118. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  119. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  120. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  121. package/dist/apps/shared/components/mail/MessageDetailPane.js +87 -36
  122. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  123. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  124. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  125. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  126. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  127. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  128. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +122 -27
  129. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  130. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  131. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  132. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  133. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
  134. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
  135. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  136. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  137. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  138. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  139. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +19 -4
  140. package/dist/apps/shared/components/mail/layout/MailShell.js +85 -85
  141. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  142. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  143. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  144. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  145. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  146. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  147. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  148. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  149. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  150. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  151. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  152. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  153. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  154. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  155. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  156. package/dist/apps/shared/keyboard/format.js +74 -0
  157. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  158. package/dist/apps/shared/keyboard/keymap.js +84 -0
  159. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  160. package/dist/apps/shared/keyboard/match.js +31 -0
  161. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  162. package/dist/apps/shared/keyboard/parse.js +107 -0
  163. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  164. package/dist/apps/shared/keyboard/platform.js +22 -0
  165. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  166. package/dist/apps/shared/keyboard/registry.js +33 -0
  167. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  168. package/dist/apps/shared/keyboard/targets.js +67 -0
  169. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  170. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  171. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  172. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  173. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  174. package/dist/apps/shared/mail/folderCounts.js +212 -0
  175. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  176. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  177. package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
  178. package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
  179. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  180. package/dist/apps/shared/mail/messageReadState.js +63 -0
  181. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  182. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  183. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  184. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  185. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +55 -0
  186. package/dist/apps/shared/mail/useMailLiveUpdates.js +182 -0
  187. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  188. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  189. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  190. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  191. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  192. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  193. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  194. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  195. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  196. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  197. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  198. package/dist/apps/shared/navigation/frameContext.js +24 -0
  199. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  200. package/dist/apps/shared/navigation/idle.js +43 -0
  201. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  202. package/dist/apps/shared/navigation/routerContext.js +56 -0
  203. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  204. package/dist/apps/shared/navigation/routes.js +37 -0
  205. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  206. package/dist/apps/shared/styles/app.css +28 -10
  207. package/dist/apps/www/_routedPage.d.ts +12 -0
  208. package/dist/apps/www/_routedPage.js +19 -0
  209. package/dist/apps/www/_routes.d.ts +11 -0
  210. package/dist/apps/www/_routes.js +29 -0
  211. package/dist/apps/www/calendar/index.d.ts +2 -2
  212. package/dist/apps/www/calendar/index.js +39 -8
  213. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  214. package/dist/apps/www/contacts/[uid].js +6 -2
  215. package/dist/apps/www/contacts/index.d.ts +2 -2
  216. package/dist/apps/www/contacts/index.js +17 -4
  217. package/dist/apps/www/index.d.ts +2 -2
  218. package/dist/apps/www/index.js +361 -34
  219. package/dist/apps/www/messages/[uid].d.ts +3 -7
  220. package/dist/apps/www/messages/[uid].js +7 -4
  221. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  222. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  223. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  224. package/dist/apps/www/settings/encryption/index.js +3 -1
  225. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  226. package/dist/apps/www/settings/filters/[uid].js +3 -1
  227. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  228. package/dist/apps/www/settings/filters/index.js +3 -1
  229. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  230. package/dist/apps/www/settings/filters/new/index.js +6 -2
  231. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  232. package/dist/apps/www/settings/labels/index.js +3 -1
  233. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  234. package/dist/apps/www/settings/privacy/index.js +3 -1
  235. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  236. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  237. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  238. package/dist/apps/www/settings/sharing/index.js +3 -1
  239. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  240. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  241. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  242. package/dist/apps/www/settings/signatures/index.js +3 -1
  243. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  244. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  245. package/dist/apps/www/tasks/index.d.ts +2 -2
  246. package/dist/apps/www/tasks/index.js +15 -3
  247. package/package.json +2 -2
@@ -1,1700 +1,1842 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { ChangeEvent, useEffect, useRef, useState } from "react";
6
- import {
7
- HiOutlineArrowsPointingIn,
8
- HiOutlineArrowsPointingOut,
9
- HiOutlineChevronDown,
10
- HiOutlineLockClosed,
11
- HiOutlineMinus,
12
- HiOutlinePaperClip,
13
- HiOutlineTrash,
14
- HiOutlineXMark,
15
- } from "react-icons/hi2";
16
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
17
- import {
18
- Attachment,
19
- ComposeRecipientInput,
20
- Mailbox,
21
- Message,
22
- assembleDraft,
23
- assembleDraftRaw,
24
- attachmentContentUrl,
25
- createDraft,
26
- deleteMessage,
27
- getMailbox,
28
- getMessage,
29
- listFolders,
30
- listMailboxes,
31
- sendMessage,
32
- setMessageRequestReceipt,
33
- uploadAttachment,
34
- } from "@rapidmx/react-shared/mail/mailApi.js";
35
- import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
36
- import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
37
- import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
38
- import { decideMessageEncryption, resolveRecipientEncryption, RecipientEncryptionStatus } from "@rapidmx/react-shared/crypto/composeSecurity.js";
39
- import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
40
- import { EncryptionPolicy, findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
41
- import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
42
- import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
43
- import { ProtectedHeaders, applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
44
- import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
45
- import type { ComposeSession } from "./ComposeContext.js";
46
- import { isSigningOut, registerComposeFlush } from "./composeFlushRegistry.js";
47
- import RecipientInput from "./RecipientInput.js";
48
- import { parseRecipientList } from "./recipients.js";
49
- import RichTextEditor from "./RichTextEditor.js";
50
- import ScheduleSendPicker from "./ScheduleSendPicker.js";
51
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
52
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
53
- import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
54
-
55
- export interface ComposeWindowProps {
56
- session: ComposeSession;
57
- onClose: () => void;
58
- onToggleMinimize: () => void;
59
- /** The signed-in user - identifies their own ("primary") mailbox, the default From when the session
60
- * doesn't name a mailbox. */
61
- userUid?: string;
62
- /** The caller holds a trusted (admin) role - every mailbox is writable, so no per-mailbox access checks. */
63
- trusted?: boolean;
64
- /** How long (ms) after the last edit the draft is autosaved. Defaults to `DEFAULT_AUTOSAVE_DELAY_MS`;
65
- * only overridden by tests. */
66
- autosaveDelayMs?: number;
67
- /** Backoff (ms) between automatic retries of a failed mailbox/encryption-policy load, one entry per retry.
68
- * Defaults to `DEFAULT_CRYPTO_RETRY_DELAYS_MS`; only overridden by tests. */
69
- cryptoRetryDelaysMs?: number[];
70
- }
71
-
72
- /** Debounce between the last edit and the draft autosave. */
73
- export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
74
-
75
- /** Automatic retries of a failed mailbox/encryption-policy load; after the last one only "Retry" tries again. */
76
- export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000];
77
-
78
- /** Signed/encrypted bodies are built client-side from the editor's HTML verbatim, so an inline image
79
- * (an uploaded attachment previewed via its content URL, or a `cid:` reference) would never be carried -
80
- * only `assembleDraft()`'s server-side plaintext path rewrites those into real MIME parts. */
81
- const INLINE_IMAGE_PATTERN = /<img\b[^>]*\bsrc\s*=\s*["']?(?:cid:|[^"'\s>]*\/mail\/attachments\/[^"'\s>]+\/content)/i;
82
-
83
- const KEYS_LOCKED_SIGN_MESSAGE =
84
- "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.";
85
- const KEYS_LOCKED_ENCRYPT_MESSAGE =
86
- "This message can't be encrypted right now - your encryption key is locked. Unlock and send again, or send it without encryption.";
87
- const POLICY_UNAVAILABLE_MESSAGE =
88
- "Your encryption settings couldn't be loaded, so this message can't be encrypted right now. Try again, or send it without encryption.";
89
- const BCC_ENCRYPTED_MESSAGE = "Bcc recipients can't be used with encrypted messages. Remove Bcc recipients or turn off encryption.";
90
- const LOOKUP_UNAVAILABLE_MESSAGE =
91
- "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.";
92
- /** Thrown by `deleteDraft()` when the server copy is no longer this window's draft (sent or scheduled elsewhere). */
93
- const NO_LONGER_A_DRAFT_MESSAGE = "This message was already sent or scheduled from another window, so it wasn't deleted.";
94
-
95
- /** The server refuses signed/encrypted mail whose From display name differs from its own safe form, which drops a
96
- * name containing `@` (or a look-alike) or a line break. */
97
- const UNSAFE_DISPLAY_NAME_PATTERN = /[@@﹫\r\n]/;
98
-
99
- /** Whether `fresh` (a re-read of a draft) is still an unsent draft in `folderUid`: never delete a message another
100
- * window has since sent, scheduled, or started sending. */
101
- function isStillDraft(fresh: Message, folderUid: string): boolean {
102
- return fresh.folderUid === folderUid && !fresh.scheduledSendTime && !fresh.scheduledSendLeaseExpiresAt && !fresh.scheduledSendRelayedAt;
103
- }
104
-
105
- /** Any tier of `policy` auto-encrypts - only then can a message with recipients still to be added turn out encrypted
106
- * without the user asking. */
107
- function policyCanAutoEncrypt(policy: EncryptionPolicy): boolean {
108
- return [policy.encryptSameOrg, policy.encryptFederated, policy.encryptExternal].includes("automatic");
109
- }
110
-
111
- const SAVE_STATUS_LABEL = { idle: "", saving: "Saving…", saved: "Draft saved", error: "Couldn't save draft" } as const;
112
-
113
- const DISCARD_TITLE = "Discard this draft?";
114
- const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so closing this window discards what you've written.";
115
- const CHECKING_CLOSE_MESSAGE =
116
- "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.";
117
- const CRYPTO_UNAVAILABLE_MESSAGE =
118
- "Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
119
- const NO_RECIPIENTS_CLOSE_MESSAGE =
120
- "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.";
121
-
122
- /** The confirmation shown before a Close/Discard throws content away (or when a Close couldn't save it). */
123
- interface ClosePrompt {
124
- title: string;
125
- message: string;
126
- /** Also offer "Retry" for loading the encryption settings. */
127
- retry?: boolean;
128
- }
129
-
130
- interface SecurityBlock {
131
- message: string;
132
- overrideLabel: string;
133
- }
134
-
135
- const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
136
- const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
137
-
138
- const MIN_WIDTH = 320;
139
- const MIN_HEIGHT = 320;
140
- // Kept well clear of the viewport edge, not flush against it — this is a resize *ceiling* (the drag
141
- // handles below clamp to it), not the "expanded" preset's own size, which stays a plain `85vh`
142
- // Tailwind class precisely so it never needs `window.innerHeight` at all: that'd have to be read at
143
- // module-evaluation time to live alongside these other constants, and `window` doesn't exist yet
144
- // during this app's SSR pass (see `ReactRoute`) — referencing it here would crash every page render,
145
- // not just this component's.
146
- const VIEWPORT_MARGIN = 24;
147
-
148
- interface Size {
149
- width: number;
150
- height: number;
151
- }
152
-
153
- type ResizeEdge = "left" | "top" | "corner";
154
-
155
- /** A To/Cc/Bcc field's recipients - see `recipients.ts` for the format. */
156
- function parseAddresses(value: string): ComposeRecipientInput[] {
157
- return parseRecipientList(value);
158
- }
159
-
160
- function HeaderButton({
161
- label,
162
- onClick,
163
- icon: Icon,
164
- disabled,
165
- }: {
166
- label: string;
167
- onClick: () => void;
168
- icon: React.ComponentType<{ size?: number }>;
169
- disabled?: boolean;
170
- }) {
171
- return (
172
- <button
173
- type="button"
174
- aria-label={label}
175
- title={label}
176
- onClick={onClick}
177
- disabled={disabled}
178
- className="w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
179
- >
180
- <Icon size={14} />
181
- </button>
182
- );
183
- }
184
-
185
- /**
186
- * A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
187
- * navigating to a dedicated page (see `ComposeContext.tsx`'s doc comment for why: a full-page compose
188
- * form meant losing your place in the inbox/calendar/contacts view behind it, and every navigation to
189
- * `/compose` re-ran this whole app's full mailbox/folder resolution waterfall from scratch). Owns its
190
- * own draft lifecycle end to end (unlike the old page, which read the Drafts folder from `MailShell`'s
191
- * context — this window can be opened from apps that never mount `MailShell` at all, e.g. Contacts'
192
- * "Email" action, so it resolves its own Drafts folder from just a `mailboxUid`).
193
- *
194
- * Below the `md` breakpoint, a non-minimized session instead renders as a full-screen sheet (there's no
195
- * room for a floating window, and the click-drag resize handles below become meaningless full-screen) —
196
- * see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
197
- * once.
198
- */
199
- export default function ComposeWindow({
200
- session,
201
- onClose,
202
- onToggleMinimize,
203
- userUid,
204
- trusted,
205
- autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS,
206
- cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS,
207
- }: ComposeWindowProps) {
208
- const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized } = session;
209
- // The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
210
- // compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
211
- // Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
212
- const [fromMailboxUid, setFromMailboxUid] = useState<string | undefined>(session.mailboxUid);
213
- const mailboxUid = fromMailboxUid;
214
- const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
215
- // Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
216
- // Drafts folder - so once anything has been uploaded the sender can no longer be switched.
217
- const [hasUploads, setHasUploads] = useState(false);
218
- const isMobile = useIsMobile();
219
- const { requestUnlock } = useUnlockPrompt();
220
- // Bumped after a successful on-demand unlock to force a re-render - `getUnlockedKeys()` below is a
221
- // plain read from a module-level store, not React state, so nothing else would pick up the change.
222
- const [, setUnlockRefresh] = useState(0);
223
-
224
- const windowRef = useRef<HTMLDivElement>(null);
225
- const [draftsFolderUid, setDraftsFolderUid] = useState<string | undefined>();
226
- const [folderError, setFolderError] = useState<string | null>(null);
227
- const [draft, setDraft] = useState<Message | null>(null);
228
- // Drafts replaced by a From switch, awaiting deletion until their replacement has actually been created.
229
- const supersededDraftsRef = useRef<Message[]>([]);
230
- const [draftError, setDraftError] = useState<string | null>(null);
231
- const [expanded, setExpanded] = useState(false);
232
- const [manualSize, setManualSize] = useState<Size | null>(null);
233
- const [to, setTo] = useState(initialTo ?? "");
234
- const [cc, setCc] = useState(initialCc ?? "");
235
- const [bcc, setBcc] = useState("");
236
- const [showCcBcc, setShowCcBcc] = useState(!!initialCc);
237
- const [subject, setSubject] = useState(initialSubject ?? "");
238
- const [html, setHtml] = useState("");
239
- const [contentReady, setContentReady] = useState(false);
240
- const [attachments, setAttachments] = useState<Attachment[]>([]);
241
- const [attachError, setAttachError] = useState<string | null>(null);
242
- const [sendError, setSendError] = useState<string | null>(null);
243
- const [sending, setSending] = useState(false);
244
- const [requestReceipt, setRequestReceipt] = useState(false);
245
- const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
246
- const scheduleButtonRef = useRef<HTMLButtonElement>(null);
247
- const [mailbox, setMailbox] = useState<Mailbox | null>(null);
248
- const [encryptionPolicy, setEncryptionPolicy] = useState<EncryptionPolicy | null>(null);
249
- // Defaults off when replying to a detected mailing list (spec's own "Mailing lists" note under
250
- // Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
251
- // `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
252
- // still turn it back on; this only changes the default.
253
- const [signEnabled, setSignEnabled] = useState(!suppressSigning);
254
- // A reply to or forward of an encrypted message starts (and, after a From switch, restarts) with encryption
255
- // requested - see `OpenComposeInput.encrypt`.
256
- const [encryptRequested, setEncryptRequested] = useState(!!initialEncrypt);
257
- const [encryptionBlocked, setEncryptionBlocked] = useState<RecipientEncryptionStatus[] | null>(null);
258
- // Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
259
- // recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
260
- // just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
261
- // never cleared, so switching focus between To/Cc/Bcc repeatedly doesn't re-trigger lookups.
262
- const [recipientStatuses, setRecipientStatuses] = useState<Record<string, RecipientEncryptionStatus>>({});
263
- // Addresses whose lookup is on the wire (so the blur handlers and the prefilled-recipient effect don't
264
- // look one up twice), and a counter bumped by a From switch so a lookup for the previous sender is dropped.
265
- const lookupsInFlightRef = useRef(new Set<string>());
266
- const discoveryGenerationRef = useRef(0);
267
- // A failed lookup stores no status (it would read as "no key" and let a message that must be encrypted autosave
268
- // or send as plaintext): it's retried with the same backoff as the mailbox/policy load, and once those retries
269
- // run out the address is "exhausted" - shown as a check that couldn't be done, with Retry.
270
- const lookupFailuresRef = useRef(new Map<string, number>());
271
- const lookupRetryTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
272
- const [lookupRetryToken, setLookupRetryToken] = useState(0);
273
- const [exhaustedLookups, setExhaustedLookups] = useState<Record<string, true>>({});
274
- // A send refused because signing/encryption was wanted but can't happen right now (keys locked, the
275
- // encryption policy/mailbox couldn't be loaded, or Bcc recipients on an encrypted message) - never a
276
- // silent downgrade to plaintext; the user has to explicitly pick the override.
277
- const [securityBlock, setSecurityBlock] = useState<SecurityBlock | null>(null);
278
- // The schedule time of the send that got blocked (undefined for an immediate send), so the banners'
279
- // override buttons replay the same kind of send the user originally asked for.
280
- const [blockedScheduleIso, setBlockedScheduleIso] = useState<string | undefined>();
281
- // Which crypto toggles were ever shown during this compose session (per sending mailbox). Keys that
282
- // lock after that point must block the send rather than quietly dropping the signature/encryption.
283
- const offeredCryptoRef = useRef({ sign: false, encrypt: false });
284
-
285
- // Draft autosave / discard bookkeeping.
286
- const [seededHtml, setSeededHtml] = useState<string | undefined>();
287
- const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
288
- const [closePrompt, setClosePrompt] = useState<ClosePrompt | null>(null);
289
- /** A Close (saving) or Discard (deleting) is on the wire - the window closes once it succeeds. */
290
- const [closing, setClosing] = useState(false);
291
- const [discardError, setDiscardError] = useState<string | null>(null);
292
- /** Why the last save failed, cleared by the next successful one. */
293
- const saveErrorRef = useRef<string | null>(null);
294
- /** `<draftUid>:<content key>` of the last successful save. */
295
- const lastSavedRef = useRef<string | null>(null);
296
- /** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
297
- const pendingSaveRef = useRef(false);
298
- /** Mirrors `sending` for the page-leave/sign-out flushes, which run outside React's render cycle. */
299
- const sendingRef = useRef(false);
300
- /** The latest save request, if any - each save is chained after the one before it. Always settles (never
301
- * rejects), to the saved message, or `undefined` when that save failed. */
302
- const saveInFlightRef = useRef<Promise<Message | undefined>>(Promise.resolve(undefined));
303
- /** Set once the window has been sent/closed/discarded - nothing more gets autosaved after that. */
304
- const finishedRef = useRef(false);
305
-
306
- // A mailbox with no keys enrolled yet just means sign/encrypt stay unavailable for this compose session -
307
- // encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
308
- // `cryptoContextReady` (below) gates Send/Send-later until both calls have settled either way, so a
309
- // send that happens to race this fetch can't silently skip encryption the spec says should apply.
310
- // A failed load is retried with backoff (`cryptoRetryDelaysMs`), then on demand ("Retry"): until both have
311
- // loaded, whether this message must be encrypted is unknown, so it isn't autosaved (see `autosaveSuppressed`).
312
- const [cryptoContextReady, setCryptoContextReady] = useState(false);
313
- const [cryptoLoadFailed, setCryptoLoadFailed] = useState(false);
314
- const [cryptoRetryToken, setCryptoRetryToken] = useState(0);
315
-
316
- useEffect(() => {
317
- let cancelled = false;
318
- listMailboxes({ limit: 100 })
319
- .then((result) => {
320
- if (cancelled) {
321
- return;
322
- }
323
- setMailboxes(result);
324
- // Never default to a mailbox already known to be view-only (see the effect below for one
325
- // that turns out to be).
326
- const candidates = result.filter((mb) => peekMailboxWritability(mb, userUid, trusted) !== false);
327
- setFromMailboxUid((current) => current ?? (candidates.find((mb) => mb.ownerUserUid === userUid) ?? candidates[0])?.uid);
328
- })
329
- .catch((err) => {
330
- // Only fatal when there's no mailbox to fall back on - a session that already names one just
331
- // loses the From picker.
332
- if (!cancelled && !session.mailboxUid) {
333
- setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
334
- }
335
- });
336
- return () => {
337
- cancelled = true;
338
- };
339
- }, []);
340
-
341
- useEffect(() => {
342
- if (!mailboxUid) {
343
- return;
344
- }
345
- let cancelled = false;
346
- let timer: ReturnType<typeof setTimeout> | undefined;
347
- // A retry only fetches what's still missing.
348
- let mailboxLoaded = mailbox?.uid === mailboxUid;
349
- let policyLoaded = !!encryptionPolicy;
350
- let retries = 0;
351
- setCryptoContextReady(false);
352
- setCryptoLoadFailed(false);
353
- async function load() {
354
- await Promise.all([
355
- mailboxLoaded ||
356
- getMailbox(mailboxUid!).then(
357
- (result) => {
358
- mailboxLoaded = true;
359
- if (!cancelled) {
360
- setMailbox(result);
361
- }
362
- },
363
- () => undefined,
364
- ),
365
- policyLoaded ||
366
- getEncryptionPolicy().then(
367
- (result) => {
368
- policyLoaded = true;
369
- if (!cancelled) {
370
- setEncryptionPolicy(result);
371
- }
372
- },
373
- () => undefined,
374
- ),
375
- ]);
376
- if (cancelled) {
377
- return;
378
- }
379
- const failed = !mailboxLoaded || !policyLoaded;
380
- setCryptoContextReady(true);
381
- setCryptoLoadFailed(failed);
382
- if (failed && retries < cryptoRetryDelaysMs.length) {
383
- timer = setTimeout(() => void load(), cryptoRetryDelaysMs[retries++]);
384
- }
385
- }
386
- void load();
387
- return () => {
388
- cancelled = true;
389
- clearTimeout(timer);
390
- };
391
- }, [mailboxUid, cryptoRetryToken]);
392
-
393
- useEffect(() => {
394
- if (!mailboxUid) {
395
- return;
396
- }
397
- // Cancellable so a slow response for a mailbox the user has since switched away from can't point
398
- // the new draft at the wrong mailbox's Drafts folder.
399
- let cancelled = false;
400
- listFolders(mailboxUid)
401
- .then((folders) => {
402
- if (!cancelled) {
403
- setDraftsFolderUid(folders.find((f) => f.type === "drafts")?.uid);
404
- }
405
- })
406
- .catch((err) => {
407
- if (!cancelled) {
408
- setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder.");
409
- }
410
- });
411
- return () => {
412
- cancelled = true;
413
- };
414
- }, [mailboxUid]);
415
-
416
- /**
417
- * Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
418
- * caching the result so the small per-recipient indicator below the recipient fields can update
419
- * without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
420
- * and not the same lookup `assembleForSend()` performs again at send time (deliberately: this is a
421
- * best-effort UI hint, not something a stale value here should be trusted to skip at send time).
422
- * A mailbox/policy fetch that hasn't resolved yet just leaves that address with no indicator - the effect
423
- * below runs it again for every current recipient once both have loaded. A failed lookup leaves it without
424
- * a status too, and is retried after `cryptoRetryDelaysMs` (then only on blur, Close or Retry).
425
- *
426
- * The statuses also drive autosave: a recipient without one yet (lookup pending, failed or never run) or a
427
- * decision that would auto-encrypt keeps the plaintext draft save off (see `autosaveSuppressed`).
428
- */
429
- function checkRecipientDiscovery(addresses: ComposeRecipientInput[]) {
430
- if (!mailbox || !encryptionPolicy) {
431
- return;
432
- }
433
- const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
434
- const generation = discoveryGenerationRef.current;
435
- const inFlight = lookupsInFlightRef.current;
436
- const unchecked = addresses.filter((r) => !(r.address in recipientStatuses) && !inFlight.has(r.address));
437
- for (const recipient of unchecked) {
438
- inFlight.add(recipient.address);
439
- void lookupKeys(mailbox.uid, recipient.address)
440
- .then(
441
- (lookup) => ({ lookup }),
442
- () => null,
443
- )
444
- .then((result) => {
445
- inFlight.delete(recipient.address);
446
- if (generation !== discoveryGenerationRef.current) {
447
- return;
448
- }
449
- if (!result) {
450
- noteLookupFailed(recipient.address);
451
- return;
452
- }
453
- lookupFailuresRef.current.delete(recipient.address);
454
- const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
455
- setRecipientStatuses((prev) => ({ ...prev, [recipient.address]: status }));
456
- });
457
- }
458
- }
459
-
460
- /** Schedules the next automatic retry of a failed lookup, or marks the address exhausted once they've run out. */
461
- function noteLookupFailed(address: string) {
462
- const failures = (lookupFailuresRef.current.get(address) ?? 0) + 1;
463
- lookupFailuresRef.current.set(address, failures);
464
- if (failures > cryptoRetryDelaysMs.length) {
465
- setExhaustedLookups((prev) => ({ ...prev, [address]: true }));
466
- return;
467
- }
468
- const timer = setTimeout(() => {
469
- lookupRetryTimersRef.current.delete(timer);
470
- setLookupRetryToken((n) => n + 1);
471
- }, cryptoRetryDelaysMs[failures - 1]);
472
- lookupRetryTimersRef.current.add(timer);
473
- }
474
-
475
- useEffect(
476
- () => () => {
477
- for (const timer of lookupRetryTimersRef.current) {
478
- clearTimeout(timer);
479
- }
480
- },
481
- [],
482
- );
483
-
484
- // Prefilled recipients (a reply's To/Cc) are never blurred, and a blur before the mailbox/policy loaded
485
- // was ignored - look every current recipient up as soon as both are available (and again on a lookup retry).
486
- useEffect(() => {
487
- checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
488
- }, [mailbox, encryptionPolicy, lookupRetryToken]);
489
-
490
- // Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
491
- // plus any quoted original message (laid out by `buildComposeBodyHtml()`: an empty first paragraph for the
492
- // caret, then the signature, then the quote), before `RichTextEditor` ever mounts (gated by `contentReady`
493
- // below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
494
- // content and never re-syncs from a later prop change, so this has to resolve before that first
495
- // mount, not after. A signature-list failure is best-effort, same as every other supplementary,
496
- // non-blocking fetch in this codebase (e.g. `ConversationList`'s own child-message fetch) — no
497
- // signature is a completely legitimate outcome, so this falls back to just the quoted content
498
- // (if any) rather than surfacing an error over what's a cosmetic nicety.
499
- useEffect(() => {
500
- // Seeds the body once, from whichever mailbox is the sender when compose opens - switching From
501
- // later must not overwrite what the user has already written (the editor itself never re-syncs).
502
- if (!mailboxUid || contentReady) {
503
- return;
504
- }
505
- listMailSignatures(mailboxUid)
506
- .then((signatures) => {
507
- const signature = signatures.find((s) =>
508
- signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward,
509
- );
510
- const seeded = buildComposeBodyHtml(signature?.contentHtml, initialQuotedHtml);
511
- setHtml(seeded);
512
- setSeededHtml(seeded);
513
- })
514
- .catch(() => {
515
- const seeded = buildComposeBodyHtml(undefined, initialQuotedHtml);
516
- setHtml(seeded);
517
- setSeededHtml(seeded);
518
- })
519
- .finally(() => setContentReady(true));
520
- }, [mailboxUid, signatureContext, initialQuotedHtml]);
521
-
522
- useEffect(() => {
523
- if (!mailboxUid || !draftsFolderUid || draft) {
524
- return;
525
- }
526
- let cancelled = false;
527
- // `session.threading` is what makes a reply a reply: the server writes it into the relayed MIME's
528
- // `In-Reply-To`/`References` and groups the message into the thread being answered. Recorded on
529
- // every draft this window creates, including the replacement one a From switch makes - the thread
530
- // doesn't change because the sending mailbox did.
531
- createDraft(mailboxUid, draftsFolderUid, session.threading)
532
- .then((created) => {
533
- if (cancelled) {
534
- // The sender changed while this was in flight - discard the now-orphaned draft.
535
- void deleteMessage(created.uid, created.version).catch(() => undefined);
536
- return;
537
- }
538
- setDraft(created);
539
- // Only now that the replacement exists is it safe to discard the draft(s) a From switch
540
- // superseded - if creating this one had failed, the earlier draft is still there.
541
- for (const superseded of supersededDraftsRef.current.splice(0)) {
542
- void deleteSupersededDraft(superseded);
543
- }
544
- })
545
- .catch((err) => {
546
- if (!cancelled) {
547
- setDraftError(err instanceof ApiRequestError ? err.message : "Could not start a new draft.");
548
- }
549
- });
550
- return () => {
551
- cancelled = true;
552
- };
553
- }, [mailboxUid, draftsFolderUid, draft, session.threading]);
554
-
555
- // Drafts a From switch superseded are otherwise only deleted once their replacement exists - closing the
556
- // window (or sending, which closes it) first would orphan them.
557
- useEffect(
558
- () => () => {
559
- for (const superseded of supersededDraftsRef.current.splice(0)) {
560
- void deleteSupersededDraft(superseded);
561
- }
562
- },
563
- [],
564
- );
565
-
566
- /**
567
- * Deletes a draft a From switch replaced. An autosave of it may still be on the wire - it bumps the
568
- * draft's version (see `saveDraftNow()`, which keeps `superseded.version` current while it's queued), so
569
- * the delete waits for it and uses its version; a delete that still fails (e.g. another save of it landed
570
- * meanwhile) is retried once with the version the server currently holds - unless it's no longer a draft
571
- * (another window sent or scheduled it), which is left alone.
572
- * Best-effort, never rejects.
573
- */
574
- async function deleteSupersededDraft(superseded: Message): Promise<void> {
575
- const saved = await saveInFlightRef.current;
576
- // Once it's out of `supersededDraftsRef` a landing save can't update `superseded.version` any more.
577
- const version = saved?.uid === superseded.uid ? saved.version : superseded.version;
578
- try {
579
- await deleteMessage(superseded.uid, version);
580
- } catch {
581
- try {
582
- const fresh = await getMessage(superseded.uid);
583
- if (isStillDraft(fresh, superseded.folderUid)) {
584
- await deleteMessage(fresh.uid, fresh.version);
585
- }
586
- } catch {
587
- // Already gone, or the server is unreachable - nothing more to do from here.
588
- }
589
- }
590
- }
591
-
592
- // The From picker lists every mailbox straight away and drops view-only ones as each check answers - only
593
- // mailboxes the caller can create a draft in are worth offering.
594
- const writability = useMailboxWritability(mailboxes, userUid, trusted);
595
- const fromOptions = mailboxes.filter((mb) => mb.uid === mailboxUid || writability[mb.uid] !== false);
596
-
597
- // The sender turned out to be view-only (e.g. a reply to a message in a mailbox shared read-only, or a
598
- // first-listed shared mailbox): switch to one the caller can actually send from, preferring their own.
599
- const fromIsViewOnly = !!mailboxUid && writability[mailboxUid] === false;
600
- useEffect(() => {
601
- if (!fromIsViewOnly || hasUploads) {
602
- return;
603
- }
604
- const ordered = [...mailboxes.filter((mb) => mb.ownerUserUid === userUid), ...mailboxes.filter((mb) => mb.ownerUserUid !== userUid)];
605
- const alternative = ordered.find((mb) => writability[mb.uid] === true) ?? ordered.find((mb) => writability[mb.uid] === undefined);
606
- if (alternative) {
607
- handleFromChange(alternative.uid);
608
- }
609
- }, [fromIsViewOnly, writability]);
610
-
611
- /** Switches the sending mailbox: supersedes the current (unsent, upload-free) draft and resets the
612
- * per-mailbox state, so the effects above start a fresh draft and crypto context in the new mailbox -
613
- * the old draft is deleted only once the new one has been created. Recipients, subject, and body are
614
- * kept. */
615
- function handleFromChange(nextMailboxUid: string) {
616
- if (nextMailboxUid === mailboxUid || hasUploads) {
617
- return;
618
- }
619
- if (draft) {
620
- supersededDraftsRef.current.push(draft);
621
- }
622
- setDraft(null);
623
- setDraftsFolderUid(undefined);
624
- setDraftError(null);
625
- setFolderError(null);
626
- setMailbox(null);
627
- setRecipientStatuses({});
628
- discoveryGenerationRef.current += 1;
629
- lookupsInFlightRef.current = new Set();
630
- lookupFailuresRef.current = new Map();
631
- setExhaustedLookups({});
632
- setEncryptRequested(!!initialEncrypt);
633
- setEncryptionBlocked(null);
634
- setSecurityBlock(null);
635
- offeredCryptoRef.current = { sign: false, encrypt: false };
636
- setFromMailboxUid(nextMailboxUid);
637
- }
638
-
639
- /**
640
- * Click-and-drag resize, from the window's own top/left edges (and the top-left corner, for both
641
- * at once) — the window is anchored bottom-right (see `ComposeContext.tsx`'s stack), so growing it
642
- * always means extending up and/or left, never down/right. `onMove`/`onUp` are fresh closures
643
- * created per drag gesture (captured over this gesture's own start position/size) rather than
644
- * stable component-level functions, specifically so `removeEventListener` in `onUp` always removes
645
- * the exact listener `onMove`/`onUp` themselves just added — a version defined once per render and
646
- * reused across gestures would need extra ref-juggling to avoid removing a stale (already-replaced)
647
- * listener instead of the current one.
648
- */
649
- function handleResizeStart(edge: ResizeEdge) {
650
- return (e: React.PointerEvent) => {
651
- e.preventDefault();
652
- const rect = windowRef.current!.getBoundingClientRect();
653
- const startX = e.clientX;
654
- const startY = e.clientY;
655
- const startWidth = rect.width;
656
- const startHeight = rect.height;
657
-
658
- function onMove(ev: PointerEvent) {
659
- const maxWidth = window.innerWidth - VIEWPORT_MARGIN;
660
- const maxHeight = window.innerHeight - VIEWPORT_MARGIN;
661
- const width = edge === "top" ? startWidth : Math.min(Math.max(startWidth - (ev.clientX - startX), MIN_WIDTH), maxWidth);
662
- const height = edge === "left" ? startHeight : Math.min(Math.max(startHeight - (ev.clientY - startY), MIN_HEIGHT), maxHeight);
663
- setManualSize({ width, height });
664
- }
665
- function onUp() {
666
- window.removeEventListener("pointermove", onMove);
667
- window.removeEventListener("pointerup", onUp);
668
- }
669
- window.addEventListener("pointermove", onMove);
670
- window.addEventListener("pointerup", onUp);
671
- };
672
- }
673
-
674
- function toggleExpanded() {
675
- setExpanded((v) => !v);
676
- setManualSize(null);
677
- }
678
-
679
- /** Uploads `file` as an attachment on the current draft, resolving to a URL the editor can preview
680
- * it at immediately — see `BaseMailComposeRoute.rewriteInlineImageSources()`'s doc comment for how
681
- * that URL gets swapped for the message's real inline `cid:` reference at send time. Resolves to
682
- * `null` (surfacing `attachError` itself, reusing the same state "Attach files" already has) rather
683
- * than throwing: unlike "Attach files" (`disabled={!draft}`), "Insert image" in the formatting
684
- * toolbar is only gated on the *editor* being mounted, not on the draft existing yet — those two
685
- * things load in parallel, so a real (if narrow) window exists where the button is clickable before
686
- * `draft` resolves, and this needs to fail gracefully rather than crash on a null draft.
687
- */
688
- async function handleUploadImage(file: File): Promise<string | null> {
689
- if (!draft) {
690
- setAttachError("Please wait for the draft to finish loading before inserting an image.");
691
- return null;
692
- }
693
- try {
694
- const attachment = await uploadAttachment(draft.uid, file);
695
- setHasUploads(true);
696
- void refreshDraftVersion(draft.uid);
697
- return attachmentContentUrl(attachment.uid);
698
- } catch (err) {
699
- setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
700
- return null;
701
- }
702
- }
703
-
704
- /** Uploading an attachment bumps the draft's version server-side (it records `hasAttachments`), so a later
705
- * Discard would otherwise send a stale `?version=`. Best-effort: Discard also retries with the server's
706
- * current version. */
707
- async function refreshDraftVersion(uid: string) {
708
- try {
709
- const fresh = await getMessage(uid);
710
- setDraft((prev) => (prev?.uid === fresh.uid && fresh.version > prev.version ? { ...prev, version: fresh.version } : prev));
711
- } catch {
712
- // Discard's own retry covers a version this couldn't refresh.
713
- }
714
- }
715
-
716
- // The "Attach files" input is itself `disabled` until `draft` resolves (see its `disabled={!draft}`
717
- // below), so this can only ever fire once `draft` is set — no defensive null check needed, matching
718
- // this codebase's established pattern for the same class of "always non-null by the time it's
719
- // called" value (e.g. `ComposeToolbar.run()`). No separate empty-`files` guard either: the loop
720
- // below is already a no-op when there's nothing to iterate.
721
- async function handleFilesSelected(e: ChangeEvent<HTMLInputElement>) {
722
- const files = Array.from(e.target.files ?? []);
723
- e.target.value = "";
724
- setAttachError(null);
725
- for (const file of files) {
726
- try {
727
- const attachment = await uploadAttachment(draft!.uid, file);
728
- setHasUploads(true);
729
- setAttachments((prev) => [...prev, attachment]);
730
- void refreshDraftVersion(draft!.uid);
731
- } catch (err) {
732
- setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
733
- }
734
- }
735
- }
736
-
737
- /**
738
- * Builds this draft's final body — plaintext, signed-only, or signed+encrypted per
739
- * `specs/end-to-end_encryption.md` — then stores it via `assembleDraft()`/`assembleDraftRaw()`.
740
- * Shared by immediate and scheduled sends (and the banners' override buttons rendered below, via
741
- * `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
742
- *
743
- * Returns `"blocked"` (never throws for this case) whenever the message can't go out the way the user
744
- * asked for — never a silent downgrade to plaintext:
745
- * encryption was requested (or auto-applies) but not every recipient can currently be encrypted to -
746
- * the spec's "Multiple Recipients" all-or-nothing rule (sets `encryptionBlocked`); signing/encryption was
747
- * on offer this session but the keys have since locked, or the mailbox/encryption policy needed to decide
748
- * couldn't be loaded (sets `securityBlock`, and re-prompts the unlock dialog when the keys are merely
749
- * locked); or the message would be encrypted but has Bcc recipients - every recipient's certificate goes
750
- * into one shared envelope, which would disclose the Bcc list to every other recipient, and the send API
751
- * has no per-recipient-copy submission to avoid that (sets `securityBlock`).
752
- * Callers must stop (not send) when they get this back. `forcePlaintext` (the user's explicit choice
753
- * from one of those banners) skips every one of these checks and never encrypts; signing still
754
- * happens if it's currently possible.
755
- *
756
- * Discovery (`lookupKeys()`) and the resulting sign/encrypt decision only run at this, the final
757
- * pre-send step — `checkRecipientDiscovery()`'s on-blur badges are a best-effort hint only.
758
- */
759
- async function assembleForSend(
760
- toRecipients: ComposeRecipientInput[],
761
- ccRecipients: ComposeRecipientInput[],
762
- bccRecipients: ComposeRecipientInput[],
763
- forcePlaintext: boolean,
764
- ): Promise<Message | "blocked"> {
765
- // Keys are read at the moment they're needed, and again after any await: they can lock (idle
766
- // timeout, sign-out elsewhere) mid-send, and a destroyed `UnlockedKeys` object must count as locked.
767
- // Only reachable once a draft exists, which itself requires `mailboxUid` to have resolved.
768
- function readKeys() {
769
- const stored = getUnlockedKeys(mailboxUid!);
770
- const unlocked = stored?.destroyed ? undefined : stored;
771
- return {
772
- unlocked,
773
- // `mailbox` is required to build protected headers (own From address) whenever signing or
774
- // encrypting - not just guarded on the encryption branch below, since a signed-only message
775
- // needs it too.
776
- canSign: signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer,
777
- canEncryptSelf: !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer,
778
- };
779
- }
780
- let keys = readKeys();
781
- const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
782
-
783
- // The Sign toggle was shown (and is still checked), but signing can't actually happen any more.
784
- const signingLost = () => !forcePlaintext && signEnabled && offeredCryptoRef.current.sign && !keys.canSign;
785
- if (signingLost()) {
786
- return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
787
- }
788
-
789
- if (!forcePlaintext && !mailbox) {
790
- // The mailbox's enrolled keys are what say whether this message could be encrypted at all - with keys
791
- // that aren't unlocked in this session, a failed mailbox load would otherwise skip encryption silently.
792
- return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
793
- }
794
-
795
- let wantEncrypt = false;
796
- let recipientCertDers: Uint8Array[] = [];
797
- // Entered whenever this message could be encrypted at all - including a mailbox with an enrolled
798
- // encryption key that this session never unlocked: policy may still auto-encrypt, and that must block
799
- // (and prompt to unlock) rather than quietly send plaintext.
800
- if (!forcePlaintext && (keys.canEncryptSelf || offeredCryptoRef.current.encrypt || hasEnrolledEncryptionKey)) {
801
- if (!encryptionPolicy) {
802
- // Without it, there's no telling whether policy auto-encrypts this message.
803
- return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
804
- }
805
- // Reachable only with `mailbox` loaded (checked above).
806
- const ownPrefersMutual = mailbox!.encryptPreference?.preferEncrypt === "mutual";
807
- const lookups = await Promise.all(
808
- allRecipients.map((r) =>
809
- lookupKeys(mailboxUid!, r.address).then(
810
- (lookup) => ({ lookup }),
811
- () => null,
812
- ),
813
- ),
814
- );
815
- keys = readKeys();
816
- if (signingLost()) {
817
- return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
818
- }
819
- // A failed lookup isn't "no key": treating it as one could send plaintext that policy would encrypt.
820
- if (lookups.some((result) => !result) && (encryptRequested || policyCanAutoEncrypt(encryptionPolicy))) {
821
- return blockSend(LOOKUP_UNAVAILABLE_MESSAGE, "Send without encryption", false);
822
- }
823
- const statuses = allRecipients.map((r, i) =>
824
- resolveRecipientEncryption(mailbox!.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]?.lookup),
825
- );
826
- const decision = decideMessageEncryption(statuses);
827
- wantEncrypt = encryptRequested || decision.autoEncrypt;
828
- if (wantEncrypt) {
829
- if (!keys.canEncryptSelf) {
830
- return blockSend(KEYS_LOCKED_ENCRYPT_MESSAGE, "Send without encryption", !keys.unlocked);
831
- }
832
- if (!decision.canEncryptAll) {
833
- setEncryptionBlocked(decision.blockedRecipients);
834
- return "blocked";
835
- }
836
- if (bccRecipients.length > 0) {
837
- return blockSend(BCC_ENCRYPTED_MESSAGE, "Send without encryption", false);
838
- }
839
- recipientCertDers = [keys.unlocked!.encryptionCertDer!, ...statuses.map((s) => fromBase64(s.encryptCert!.publicKey))];
840
- }
841
- }
842
-
843
- const { canSign } = keys;
844
- if ((wantEncrypt || canSign) && attachments.length > 0) {
845
- // Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
846
- // clearer explanation than that route's generic 400 rather than via a round trip.
847
- throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
848
- }
849
- if ((wantEncrypt || canSign) && INLINE_IMAGE_PATTERN.test(html)) {
850
- throw new ApiRequestError("A signed or encrypted message cannot include inline images yet - remove them before sending.", 400);
851
- }
852
-
853
- if (!wantEncrypt && !canSign) {
854
- // See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
855
- // gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
856
- // client-side pass is done here either.
857
- return assembleDraft(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject, html });
858
- }
859
-
860
- const domain = mailbox!.primarySmtpAddress.split("@")[1] ?? "localhost";
861
- const displayName = mailbox!.displayName && !UNSAFE_DISPLAY_NAME_PATTERN.test(mailbox!.displayName) ? mailbox!.displayName : "";
862
- const protectedHeaders: ProtectedHeaders = {
863
- from: displayName
864
- ? `"${displayName.replace(/"/g, '\\"')}" <${mailbox!.primarySmtpAddress}>`
865
- : mailbox!.primarySmtpAddress,
866
- to: toRecipients.map((r) => r.address).join(", "),
867
- cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
868
- date: new Date().toUTCString(),
869
- subject,
870
- messageId: `<${crypto.randomUUID()}@${domain}>`,
871
- };
872
- const bodyContentType = 'text/html; charset="utf-8"';
873
- const signing = canSign ? { certDer: keys.unlocked!.signingCertDer!, privateKey: keys.unlocked!.signingPrivateKey! } : undefined;
874
-
875
- const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
876
- const mimePart = wantEncrypt
877
- ? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
878
- : await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing!.certDer, signing!.privateKey);
879
- const rawMime = assembleOutboundMime(outerHeaders, mimePart);
880
- return assembleDraftRaw(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
881
- }
882
-
883
- /** Records why a send was refused (see `assembleForSend()`), re-prompting the unlock dialog when the
884
- * cause is keys that are enrolled but locked. */
885
- function blockSend(message: string, overrideLabel: string, keysLocked: boolean): "blocked" {
886
- setSecurityBlock({ message, overrideLabel });
887
- if (keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
888
- void handleUnlockForCrypto();
889
- }
890
- return "blocked";
891
- }
892
-
893
- /**
894
- * Sends (or, given `scheduledSendTimeIso`, schedules) the draft. Same reasoning as `handleFilesSelected`
895
- * above: Send and the "Send later" caret are both `disabled` until `draft` resolves, so this is never
896
- * reachable with a null `draft`. A scheduled send assembles the draft first (same as an immediate send),
897
- * then sets `scheduledSendTime` on the *freshly assembled* copy before calling `sendMessage()` — `send()`
898
- * itself is what reads the field and defers relay into Outbox instead of sending immediately (see
899
- * `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`). A blocked send remembers its schedule
900
- * time so the banners' override buttons replay it as the same kind of send.
901
- */
902
- async function submit(forcePlaintext: boolean, scheduledSendTimeIso?: string) {
903
- const toRecipients = parseAddresses(to);
904
- if (toRecipients.length === 0) {
905
- setSendError("At least one recipient is required.");
906
- return;
907
- }
908
-
909
- setSending(true);
910
- sendingRef.current = true;
911
- setSendError(null);
912
- setSecurityBlock(null);
913
- setEncryptionBlocked(null);
914
- pendingSaveRef.current = false;
915
- try {
916
- // An autosave already on the wire must never land after (and overwrite) the send's own assembly.
917
- await saveInFlightRef.current;
918
- const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), forcePlaintext);
919
- if (assembled === "blocked") {
920
- setBlockedScheduleIso(scheduledSendTimeIso);
921
- return;
922
- }
923
- // The draft now holds exactly this content (possibly signed/encrypted) - don't autosave a
924
- // plaintext copy over it if the rest of the send fails.
925
- lastSavedRef.current = `${assembled.uid}:${contentKey}`;
926
- const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
927
- await sendMessage(withReceipt.uid, scheduledSendTimeIso ? { scheduledSendTime: scheduledSendTimeIso } : undefined);
928
- finishedRef.current = true;
929
- onClose();
930
- } catch (err) {
931
- const fallback = scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.";
932
- setSendError(err instanceof ApiRequestError ? err.message : fallback);
933
- } finally {
934
- sendingRef.current = false;
935
- setSending(false);
936
- }
937
- }
938
-
939
- function handleScheduleSend(scheduledSendTimeIso: string) {
940
- setSchedulePickerOpen(false);
941
- void submit(false, scheduledSendTimeIso);
942
- }
943
-
944
- async function handleUnlockForCrypto() {
945
- try {
946
- // Only reachable when hasEnrolledSigningKey/hasEnrolledEncryptionKey found a real enrolled key
947
- // in mailbox.keys (the unlock button below, or blockSend()), so neither is empty/undefined here.
948
- await requestUnlock(mailbox!.uid, mailbox!.keys!);
949
- setUnlockRefresh((n) => n + 1);
950
- } catch {
951
- // User dismissed the unlock dialog - nothing to do, the toggles below simply stay hidden.
952
- }
953
- }
954
-
955
- /**
956
- * Saves the window's current recipients/subject/body onto its server draft via `assembleDraft()` (the
957
- * same plaintext assembly a normal send uses). Chained after any save already on the wire - two
958
- * overlapping assemblies of the same draft race on its version and the later one is rejected - and reads
959
- * `latestRef` only once it actually runs, so it saves what was last typed (skipping the request when the
960
- * save before it already stored exactly that). Whether a save may happen at all is re-checked at that same
961
- * moment: while it waited, the message may have become one that must not be stored as plaintext (Encrypt
962
- * turned on, a recipient that auto-encrypts), or the window may have started sending or finished - then it
963
- * resolves `{ skipped: true }` without a request (`message` then being the previous save's result). Never
964
- * rejects; `message` is `undefined` when the save failed.
965
- */
966
- function saveDraftNow(): Promise<{ message?: Message; skipped?: boolean }> {
967
- const target = latestRef.current.draft!;
968
- const previous = saveInFlightRef.current;
969
- pendingSaveRef.current = false;
970
- setSaveStatus("saving");
971
- const outcome = (async (): Promise<{ message?: Message; skipped?: boolean }> => {
972
- const before = await previous;
973
- const current = latestRef.current;
974
- if (current.autosaveSuppressed || sendingRef.current || finishedRef.current) {
975
- setSaveStatus("idle");
976
- // Still the newest copy the server holds, for the version a later delete needs.
977
- return { skipped: true, message: before };
978
- }
979
- const savedKey = `${target.uid}:${current.contentKey}`;
980
- if (before?.uid === target.uid && lastSavedRef.current === savedKey) {
981
- setSaveStatus("saved");
982
- return { message: before };
983
- }
984
- try {
985
- const saved = await assembleDraft(target.uid, {
986
- to: parseAddresses(current.to),
987
- cc: parseAddresses(current.cc),
988
- bcc: parseAddresses(current.bcc),
989
- subject: current.subject,
990
- html: current.html,
991
- });
992
- lastSavedRef.current = savedKey;
993
- saveErrorRef.current = null;
994
- // A From switch may have replaced the draft while this was in flight - never resurrect it,
995
- // but do keep its superseded copy's version current so its pending delete still matches.
996
- setDraft((prev) => (prev?.uid === saved.uid ? saved : prev));
997
- const superseded = supersededDraftsRef.current.find((d) => d.uid === saved.uid);
998
- if (superseded) {
999
- superseded.version = saved.version;
1000
- }
1001
- setSaveStatus("saved");
1002
- return { message: saved };
1003
- } catch (err) {
1004
- saveErrorRef.current = err instanceof ApiRequestError ? err.message : "the server couldn't be reached";
1005
- setSaveStatus("error");
1006
- return {};
1007
- }
1008
- })();
1009
- saveInFlightRef.current = outcome.then((result) => result.message);
1010
- return outcome;
1011
- }
1012
-
1013
- /** Keeps the window open after its content couldn't be saved, offering Discard or Keep editing. */
1014
- function showSaveFailedPrompt() {
1015
- setClosePrompt({
1016
- title: "Couldn't save this draft",
1017
- message: `It couldn't be saved (${saveErrorRef.current!.replace(/\.$/, "")}). Keep editing and try again, or discard it.`,
1018
- });
1019
- }
1020
-
1021
- /**
1022
- * Deletes `current` (this window's server draft) once any save already on the wire has landed, so the delete
1023
- * carries the draft's current version. A delete rejected for a stale version (404/409: e.g. an attachment
1024
- * upload bumped it) is retried once with the version the server holds - and a draft the server no longer has
1025
- * counts as deleted. The retry only happens while the server copy is still an unsent draft in the same folder.
1026
- * Rejects when the draft couldn't be deleted.
1027
- */
1028
- async function deleteDraft(current: Message): Promise<void> {
1029
- const saved = await saveInFlightRef.current;
1030
- const version = Math.max(current.version, saved?.uid === current.uid ? saved.version : 0);
1031
- try {
1032
- await deleteMessage(current.uid, version);
1033
- } catch (err) {
1034
- if (!(err instanceof ApiRequestError) || (err.status !== 404 && err.status !== 409)) {
1035
- throw err;
1036
- }
1037
- const fresh = await getMessage(current.uid).catch((getErr: unknown) => {
1038
- if (getErr instanceof ApiRequestError && getErr.status === 404) {
1039
- return null;
1040
- }
1041
- throw getErr;
1042
- });
1043
- if (!fresh) {
1044
- return;
1045
- }
1046
- // The version changed because another window sent or scheduled it - that message isn't ours to delete.
1047
- if (!isStillDraft(fresh, current.folderUid)) {
1048
- throw new ApiRequestError(NO_LONGER_A_DRAFT_MESSAGE, 409);
1049
- }
1050
- await deleteMessage(fresh.uid, fresh.version);
1051
- }
1052
- }
1053
-
1054
- /**
1055
- * Discards the window's draft and closes it. With nothing the user would lose (a blank draft) it closes
1056
- * straight away and deletes the draft in the background; otherwise it closes only once the draft is
1057
- * deleted, and a delete that fails keeps the window open with the error.
1058
- */
1059
- async function discardNow() {
1060
- finishedRef.current = true;
1061
- pendingSaveRef.current = false;
1062
- setClosePrompt(null);
1063
- setDiscardError(null);
1064
- const current = draft;
1065
- if (!current || !hasUserContent) {
1066
- if (current) {
1067
- void deleteDraft(current).catch(() => undefined);
1068
- }
1069
- onClose();
1070
- return;
1071
- }
1072
- setClosing(true);
1073
- try {
1074
- await deleteDraft(current);
1075
- } catch (err) {
1076
- finishedRef.current = false;
1077
- setClosing(false);
1078
- setDiscardError(`Couldn't discard this draft: ${err instanceof ApiRequestError ? err.message : "the server couldn't be reached."}`);
1079
- return;
1080
- }
1081
- onClose();
1082
- }
1083
-
1084
- /** "Discard draft": confirms first whenever there's anything the user would lose. */
1085
- function handleDiscard() {
1086
- if (hasUserContent) {
1087
- setClosePrompt({ title: DISCARD_TITLE, message: "This permanently deletes this draft and everything you've written in it." });
1088
- } else {
1089
- void discardNow();
1090
- }
1091
- }
1092
-
1093
- /** "Close": keeps the draft (saving any unsaved edits first, and closing only once that save succeeded) -
1094
- * unless there's nothing in it worth keeping, or it can't be saved because it's headed for encryption, or
1095
- * whether it is can't be told yet (settings loading or unavailable, recipient lookups pending): those need
1096
- * the same confirmation as a discard. A failed save keeps the window open with Discard / Keep editing.
1097
- * Close and Discard are both disabled while a send is in progress: deleting the draft, or saving a
1098
- * plaintext copy over the message the send just assembled, mid-send would lose or leak it. */
1099
- async function handleClose() {
1100
- if (!hasUserContent) {
1101
- void discardNow();
1102
- return;
1103
- }
1104
- if (encryptionDecided) {
1105
- setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
1106
- return;
1107
- }
1108
- if (cryptoCheckUnavailable) {
1109
- setClosePrompt({ title: DISCARD_TITLE, message: CRYPTO_UNAVAILABLE_MESSAGE, retry: true });
1110
- return;
1111
- }
1112
- if (awaitingRecipients) {
1113
- setClosePrompt({ title: DISCARD_TITLE, message: NO_RECIPIENTS_CLOSE_MESSAGE });
1114
- return;
1115
- }
1116
- if (encryptionUndetermined) {
1117
- // A recipient typed but never blurred has no lookup running yet.
1118
- checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
1119
- setClosePrompt({ title: DISCARD_TITLE, message: CHECKING_CLOSE_MESSAGE });
1120
- return;
1121
- }
1122
- if (!draft || !contentReady) {
1123
- saveErrorRef.current = draftError ?? "the draft hasn't been created yet";
1124
- showSaveFailedPrompt();
1125
- return;
1126
- }
1127
- if (lastSavedRef.current !== `${draft.uid}:${contentKey}`) {
1128
- setClosing(true);
1129
- const outcome = await saveDraftNow();
1130
- setClosing(false);
1131
- if (outcome.skipped) {
1132
- // It became a message that mustn't be saved as plaintext while waiting on an earlier save.
1133
- setClosePrompt({ title: DISCARD_TITLE, message: latestRef.current.encryptionDecided ? ENCRYPTED_CLOSE_MESSAGE : CHECKING_CLOSE_MESSAGE });
1134
- return;
1135
- }
1136
- if (!outcome.message) {
1137
- showSaveFailedPrompt();
1138
- return;
1139
- }
1140
- }
1141
- finishedRef.current = true;
1142
- onClose();
1143
- }
1144
-
1145
- function retryCryptoContext() {
1146
- setClosePrompt(null);
1147
- setCryptoRetryToken((n) => n + 1);
1148
- lookupFailuresRef.current = new Map();
1149
- setExhaustedLookups({});
1150
- setLookupRetryToken((n) => n + 1);
1151
- }
1152
-
1153
- // A plain read from keySession.ts's module-level session store, not React state - see that module's
1154
- // own doc comment. Cheap enough to read fresh on every render rather than caching in state.
1155
- const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
1156
- const hasEnrolledSigningKey = !!findActivePublicKey(mailbox?.keys ?? [], "sign");
1157
- const hasEnrolledEncryptionKey = !!findActivePublicKey(mailbox?.keys ?? [], "encrypt");
1158
- // This mailbox has a real signing/encryption key on file, but this session hasn't unlocked it yet -
1159
- // the sign/encrypt toggles below stay hidden until it has, so this is the only way to reach them
1160
- // without first stumbling into Mail generally (which no longer force-prompts on its own - see
1161
- // `MailShell`'s `blocking={false}`). `getUnlockedKeys()` is re-read as a plain module value above, so
1162
- // bumping `unlockRefresh` (after a successful unlock, or on any key-session event for this mailbox -
1163
- // see the `subscribeKeySession()` effect) is what makes this line (and the toggles it gates) reflect
1164
- // it on the next render.
1165
- const needsUnlockForCrypto = (hasEnrolledSigningKey || hasEnrolledEncryptionKey) && !unlockedKeys;
1166
-
1167
- // Keys locking (idle timeout, logout elsewhere, ...) or unlocking from another window must update the
1168
- // toggles/unlock affordance right away, not on whatever unrelated render happens next.
1169
- useEffect(() => subscribeKeySession((event) => {
1170
- if (event.mailboxUid === mailboxUid) {
1171
- setUnlockRefresh((n) => n + 1);
1172
- }
1173
- }), [mailboxUid]);
1174
-
1175
- useEffect(() => {
1176
- offeredCryptoRef.current = {
1177
- sign: offeredCryptoRef.current.sign || !!unlockedKeys?.signingPrivateKey,
1178
- encrypt: offeredCryptoRef.current.encrypt || !!unlockedKeys?.encryptionPrivateKey,
1179
- };
1180
- });
1181
-
1182
- // Autosave. "User content" is anything beyond what compose opened with (prefilled recipients/subject,
1183
- // seeded signature/quote) - an untouched window has nothing worth saving or confirming a discard of.
1184
- const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
1185
- const baselineKey = JSON.stringify([initialTo ?? "", initialCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
1186
- const hasUserContent = contentKey !== baselineKey || hasUploads;
1187
- // A message headed for encryption is never autosaved: the draft would store its plaintext server-side,
1188
- // defeating end-to-end encryption. Until it's known not to be, it isn't either: while the mailbox (whose
1189
- // enrolled keys say whether encryption is possible at all) or the encryption policy hasn't loaded, and -
1190
- // whenever this message could be encrypted (an unlocked or enrolled encryption key) - while the policy is
1191
- // missing or any current recipient's lookup is still pending (or failed, or hasn't run) - and while there are no
1192
- // recipients at all when policy auto-encrypts some tier, since the first one added may turn encryption on.
1193
- const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
1194
- const currentStatuses = currentAddresses.map((address) => recipientStatuses[address]);
1195
- const encryptionPossible = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
1196
- /** Could be auto-encrypted, but has no recipients yet to decide by. */
1197
- const awaitingRecipients = encryptionPossible && !!encryptionPolicy && currentAddresses.length === 0 && policyCanAutoEncrypt(encryptionPolicy);
1198
- const encryptionUndetermined =
1199
- !cryptoContextReady || !mailbox || awaitingRecipients || (encryptionPossible && (!encryptionPolicy || currentStatuses.some((s) => !s)));
1200
- /** Known to be headed for encryption: requested, or policy auto-encrypts it. */
1201
- const encryptionDecided = encryptRequested || (!encryptionUndetermined && encryptionPossible && decideMessageEncryption(currentStatuses).autoEncrypt);
1202
- const autosaveSuppressed = encryptionDecided || encryptionUndetermined;
1203
- /** Loading the mailbox/policy failed (retries may still be pending) and this message's encryption hinges on it, or
1204
- * a current recipient's key lookup kept failing. */
1205
- const cryptoCheckUnavailable =
1206
- (cryptoContextReady && cryptoLoadFailed && (!mailbox || (encryptionPossible && !encryptionPolicy))) ||
1207
- (encryptionPossible && currentAddresses.some((address) => exhaustedLookups[address] && !recipientStatuses[address]));
1208
- const latestRef = useRef({ draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided });
1209
- latestRef.current = { draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided };
1210
-
1211
- useEffect(() => {
1212
- const due =
1213
- !!draft &&
1214
- contentReady &&
1215
- cryptoContextReady &&
1216
- !sending &&
1217
- !autosaveSuppressed &&
1218
- hasUserContent &&
1219
- lastSavedRef.current !== `${draft.uid}:${contentKey}`;
1220
- pendingSaveRef.current = due;
1221
- if (!due) {
1222
- return;
1223
- }
1224
- const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
1225
- return () => clearTimeout(timer);
1226
- }, [draft, contentReady, cryptoContextReady, sending, autosaveSuppressed, hasUserContent, contentKey]);
1227
-
1228
- // Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
1229
- // right away rather than losing it.
1230
- useEffect(
1231
- () => () => {
1232
- if (pendingSaveRef.current && !finishedRef.current) {
1233
- void saveDraftNow();
1234
- }
1235
- },
1236
- [],
1237
- );
1238
-
1239
- /** Saves an edit still waiting on the debounce now, and resolves once that save - or one already on the
1240
- * wire - has settled, to whether the window's content is saved. A save that failed keeps the window open
1241
- * with Discard / Keep editing. A send clears `pendingSaveRef` before it starts, so this never saves mid-send. */
1242
- async function flushPendingSave(): Promise<boolean> {
1243
- if (pendingSaveRef.current && !finishedRef.current) {
1244
- const outcome = await saveDraftNow();
1245
- if (outcome.skipped) {
1246
- // Not saved, but not a failure either: it may be headed for encryption (or was sent/discarded meanwhile).
1247
- return finishedRef.current;
1248
- }
1249
- if (!outcome.message) {
1250
- showSaveFailedPrompt();
1251
- }
1252
- return !!outcome.message;
1253
- }
1254
- await saveInFlightRef.current;
1255
- const latest = latestRef.current;
1256
- if (finishedRef.current || !latest.hasUserContent || (!!latest.draft && lastSavedRef.current === `${latest.draft.uid}:${latest.contentKey}`)) {
1257
- return true;
1258
- }
1259
- // Unsaved because the last save failed (rather than, say, an encrypted message never being saved).
1260
- if (saveErrorRef.current) {
1261
- showSaveFailedPrompt();
1262
- }
1263
- return false;
1264
- }
1265
-
1266
- // Sign Out (AppShell) waits for this before ending the session.
1267
- useEffect(() => registerComposeFlush(flushPendingSave), []);
1268
-
1269
- // Leaving the page (reload, closing the tab, following a link) with anything not saved: start a save that's
1270
- // waiting on the debounce straight away and ask the browser to confirm, so the last couple of seconds of
1271
- // typing (or content that can't be saved as a draft, or failed to) aren't lost silently. Never while signing
1272
- // out - that navigation must not be cancellable.
1273
- useEffect(() => {
1274
- function handleBeforeUnload(event: BeforeUnloadEvent) {
1275
- if (finishedRef.current || sendingRef.current || isSigningOut()) {
1276
- return;
1277
- }
1278
- const latest = latestRef.current;
1279
- const unsaved =
1280
- pendingSaveRef.current ||
1281
- latest.saveStatus === "saving" ||
1282
- (latest.hasUserContent && lastSavedRef.current !== `${latest.draft?.uid}:${latest.contentKey}`);
1283
- if (!unsaved) {
1284
- return;
1285
- }
1286
- void flushPendingSave();
1287
- event.preventDefault();
1288
- event.returnValue = "";
1289
- }
1290
- window.addEventListener("beforeunload", handleBeforeUnload);
1291
- return () => window.removeEventListener("beforeunload", handleBeforeUnload);
1292
- }, []);
1293
-
1294
- // Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
1295
- // with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
1296
- // window must not move the caret there again.
1297
- const isReplyOrForward = initialQuotedHtml !== undefined;
1298
- const fieldsFocusedRef = useRef(false);
1299
- const bodyFocusedRef = useRef(false);
1300
- const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
1301
- const focusSubjectOnMount = !isReplyOrForward && !!initialTo && !fieldsFocusedRef.current;
1302
- const focusBodyOnMount = isReplyOrForward && !bodyFocusedRef.current;
1303
-
1304
- // The editor serializes the seeded body its own way (see `RichTextEditor`'s `onInitialized`). While the body is
1305
- // still exactly what compose seeded, that serialization becomes the baseline - so an untouched reply or signature
1306
- // isn't mistaken for an edit (and autosaved) just because the editor was focused or clicked.
1307
- function handleEditorInitialized(normalized: string) {
1308
- const latest = latestRef.current;
1309
- if (latest.html === latest.seededHtml && normalized !== latest.html) {
1310
- setHtml(normalized);
1311
- setSeededHtml(normalized);
1312
- }
1313
- }
1314
- useEffect(() => {
1315
- if (!minimized) {
1316
- fieldsFocusedRef.current = true;
1317
- bodyFocusedRef.current ||= contentReady;
1318
- }
1319
- }, [minimized, contentReady]);
1320
-
1321
- const title = subject.trim() || "New Message";
1322
- const titleId = `compose-title-${id}`;
1323
-
1324
- const discardModal = (
1325
- <Modal open={closePrompt !== null} onClose={() => setClosePrompt(null)} title={closePrompt?.title ?? DISCARD_TITLE}>
1326
- <p className="text-sm text-text-muted mb-4">{closePrompt?.message}</p>
1327
- <div className="flex gap-3">
1328
- {closePrompt?.retry && (
1329
- <Button type="button" onClick={retryCryptoContext} className="!w-auto">
1330
- Retry
1331
- </Button>
1332
- )}
1333
- <Button type="button" onClick={() => void discardNow()} disabled={sending || closing} className="!w-auto">
1334
- Discard
1335
- </Button>
1336
- <Button type="button" variant="secondary" onClick={() => setClosePrompt(null)} className="!w-auto">
1337
- Keep editing
1338
- </Button>
1339
- </div>
1340
- </Modal>
1341
- );
1342
-
1343
- if (minimized) {
1344
- return (
1345
- <>
1346
- <div role="dialog" aria-label={title} className="w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal">
1347
- <div
1348
- className="h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer"
1349
- onClick={onToggleMinimize}
1350
- >
1351
- <span className="text-sm font-medium truncate">{title}</span>
1352
- <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
1353
- <HeaderButton label="Restore" onClick={onToggleMinimize} icon={HiOutlineArrowsPointingOut} />
1354
- <HeaderButton label="Discard draft" onClick={handleDiscard} icon={HiOutlineXMark} disabled={sending || closing} />
1355
- </div>
1356
- </div>
1357
- </div>
1358
- {discardModal}
1359
- </>
1360
- );
1361
- }
1362
-
1363
- return (
1364
- <>
1365
- <div
1366
- ref={windowRef}
1367
- role="dialog"
1368
- aria-labelledby={titleId}
1369
- style={!isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
1370
- className={[
1371
- "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1372
- isMobile
1373
- ? "fixed inset-0 w-full h-full rounded-none border-0"
1374
- : ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1375
- ].join(" ")}
1376
- >
1377
- {!isMobile && (
1378
- <>
1379
- <div
1380
- onPointerDown={handleResizeStart("top")}
1381
- aria-hidden="true"
1382
- className="absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20"
1383
- />
1384
- <div
1385
- onPointerDown={handleResizeStart("left")}
1386
- aria-hidden="true"
1387
- className="absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20"
1388
- />
1389
- <div
1390
- onPointerDown={handleResizeStart("corner")}
1391
- role="separator"
1392
- aria-label="Resize"
1393
- className="absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30"
1394
- />
1395
- </>
1396
- )}
1397
-
1398
- <div
1399
- className="h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer"
1400
- onClick={onToggleMinimize}
1401
- >
1402
- <span id={titleId} className="text-sm font-medium truncate">
1403
- {title}
1404
- </span>
1405
- <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
1406
- <HeaderButton label="Minimize" onClick={onToggleMinimize} icon={HiOutlineMinus} />
1407
- {!isMobile && (
1408
- <HeaderButton
1409
- label={expanded ? "Collapse" : "Expand"}
1410
- onClick={toggleExpanded}
1411
- icon={expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut}
1412
- />
1413
- )}
1414
- <HeaderButton label="Close" onClick={() => void handleClose()} icon={HiOutlineXMark} disabled={sending || closing} />
1415
- </div>
1416
- </div>
1417
-
1418
- <div className="flex-1 min-h-0 flex flex-col">
1419
- {(folderError || draftError || sendError || attachError || discardError) && (
1420
- <div className="px-3 pt-2">
1421
- {folderError && <Alert>{folderError}</Alert>}
1422
- {draftError && <Alert>{draftError}</Alert>}
1423
- {sendError && <Alert>{sendError}</Alert>}
1424
- {attachError && <Alert>{attachError}</Alert>}
1425
- {discardError && <Alert>{discardError}</Alert>}
1426
- </div>
1427
- )}
1428
-
1429
- {cryptoCheckUnavailable && (
1430
- <div className="px-3 pt-2">
1431
- <Alert>
1432
- <p className="mb-2">{CRYPTO_UNAVAILABLE_MESSAGE}</p>
1433
- <Button type="button" variant="secondary" className="!w-auto" onClick={retryCryptoContext}>
1434
- Retry
1435
- </Button>
1436
- </Alert>
1437
- </div>
1438
- )}
1439
-
1440
- {encryptionBlocked && (
1441
- <div className="px-3 pt-2">
1442
- <Alert>
1443
- <p className="mb-2">
1444
- This message can&rsquo;t be encrypted for everyone: {encryptionBlocked.map((r) => r.address).join(", ")}
1445
- {encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file"}.
1446
- Remove {encryptionBlocked.length > 1 ? "these recipients" : "this recipient"} from To/Cc/Bcc, or send the
1447
- whole message in plaintext.
1448
- </p>
1449
- <Button type="button" disabled={sending} onClick={() => void submit(true, blockedScheduleIso)}>
1450
- Send without encryption
1451
- </Button>
1452
- </Alert>
1453
- </div>
1454
- )}
1455
-
1456
- {securityBlock && (
1457
- <div className="px-3 pt-2">
1458
- <Alert>
1459
- <p className="mb-2">{securityBlock.message}</p>
1460
- <Button type="button" disabled={sending} onClick={() => void submit(true, blockedScheduleIso)}>
1461
- {securityBlock.overrideLabel}
1462
- </Button>
1463
- </Alert>
1464
- </div>
1465
- )}
1466
-
1467
- {fromOptions.length > 1 && (
1468
- <div className={FIELD_ROW}>
1469
- <label htmlFor={`compose-from-${id}`} className="text-xs text-text-muted shrink-0">
1470
- From
1471
- </label>
1472
- <select
1473
- id={`compose-from-${id}`}
1474
- className={`${FIELD_INPUT} disabled:opacity-55`}
1475
- value={mailboxUid ?? ""}
1476
- disabled={hasUploads || sending}
1477
- title={hasUploads ? "The sender can't be changed after adding attachments or images." : undefined}
1478
- onChange={(e) => handleFromChange(e.target.value)}
1479
- >
1480
- {fromOptions.map((mb) => (
1481
- <option key={mb.uid} value={mb.uid}>
1482
- {mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress}
1483
- {mb.ownerUserUid ? "" : " (shared)"}
1484
- </option>
1485
- ))}
1486
- </select>
1487
- </div>
1488
- )}
1489
-
1490
- <div className={FIELD_ROW}>
1491
- <label htmlFor={`compose-to-${id}`} className="text-xs text-text-muted shrink-0">
1492
- To
1493
- </label>
1494
- <RecipientInput
1495
- id={`compose-to-${id}`}
1496
- label="To"
1497
- value={to}
1498
- mailboxUid={mailboxUid}
1499
- onChange={setTo}
1500
- onBlur={(value) => checkRecipientDiscovery(parseAddresses(value))}
1501
- onCommit={(value) => checkRecipientDiscovery(parseAddresses(value))}
1502
- autoFocus={focusToOnMount}
1503
- />
1504
- {!showCcBcc && (
1505
- <button
1506
- type="button"
1507
- onClick={() => setShowCcBcc(true)}
1508
- className="text-xs text-text-muted hover:text-text shrink-0"
1509
- >
1510
- Cc Bcc
1511
- </button>
1512
- )}
1513
- </div>
1514
-
1515
- {showCcBcc && (
1516
- <>
1517
- <div className={FIELD_ROW}>
1518
- <label htmlFor={`compose-cc-${id}`} className="text-xs text-text-muted shrink-0">
1519
- Cc
1520
- </label>
1521
- <RecipientInput
1522
- id={`compose-cc-${id}`}
1523
- label="Cc"
1524
- value={cc}
1525
- mailboxUid={mailboxUid}
1526
- onChange={setCc}
1527
- onBlur={(value) => checkRecipientDiscovery(parseAddresses(value))}
1528
- onCommit={(value) => checkRecipientDiscovery(parseAddresses(value))}
1529
- />
1530
- </div>
1531
- <div className={FIELD_ROW}>
1532
- <label htmlFor={`compose-bcc-${id}`} className="text-xs text-text-muted shrink-0">
1533
- Bcc
1534
- </label>
1535
- <RecipientInput
1536
- id={`compose-bcc-${id}`}
1537
- label="Bcc"
1538
- value={bcc}
1539
- mailboxUid={mailboxUid}
1540
- onChange={setBcc}
1541
- onBlur={(value) => checkRecipientDiscovery(parseAddresses(value))}
1542
- onCommit={(value) => checkRecipientDiscovery(parseAddresses(value))}
1543
- />
1544
- </div>
1545
- </>
1546
- )}
1547
-
1548
- {(() => {
1549
- const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
1550
- .map((r) => recipientStatuses[r.address])
1551
- .filter((status): status is RecipientEncryptionStatus => !!status);
1552
- if (knownRecipients.length === 0) {
1553
- return null;
1554
- }
1555
- return (
1556
- <ul className="flex flex-wrap gap-1.5 px-3 pt-1.5" aria-label="Recipient encryption availability">
1557
- {knownRecipients.map((status) => (
1558
- <li
1559
- key={status.address}
1560
- className={`text-xs font-medium py-0.5 px-2 rounded-pill ${
1561
- status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"
1562
- }`}
1563
- >
1564
- {status.address} {status.canEncrypt ? "supports encryption" : "no encryption key found"}
1565
- </li>
1566
- ))}
1567
- </ul>
1568
- );
1569
- })()}
1570
-
1571
- <div className={FIELD_ROW}>
1572
- <input
1573
- aria-label="Subject"
1574
- type="text"
1575
- placeholder="Subject"
1576
- className={FIELD_INPUT}
1577
- value={subject}
1578
- autoFocus={focusSubjectOnMount}
1579
- onChange={(e) => setSubject(e.target.value)}
1580
- />
1581
- </div>
1582
-
1583
- <div className="flex-1 min-h-0 p-2">
1584
- {contentReady && (
1585
- <RichTextEditor
1586
- value={html}
1587
- onChange={setHtml}
1588
- fill
1589
- onUploadImage={handleUploadImage}
1590
- autoFocusStart={focusBodyOnMount}
1591
- onInitialized={handleEditorInitialized}
1592
- />
1593
- )}
1594
- </div>
1595
-
1596
- {attachments.length > 0 && (
1597
- <ul className="flex flex-wrap gap-2 px-3 pb-2">
1598
- {attachments.map((attachment) => (
1599
- <li key={attachment.uid} className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted">
1600
- {attachment.filename}
1601
- </li>
1602
- ))}
1603
- </ul>
1604
- )}
1605
-
1606
- <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
1607
- <input type="checkbox" checked={requestReceipt} onChange={(e) => setRequestReceipt(e.target.checked)} />
1608
- Request a read receipt
1609
- </label>
1610
-
1611
- {needsUnlockForCrypto && (
1612
- <div className="px-3 pb-1">
1613
- <button
1614
- type="button"
1615
- onClick={handleUnlockForCrypto}
1616
- className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
1617
- >
1618
- <HiOutlineLockClosed size={12} aria-hidden="true" />
1619
- Unlock to sign or encrypt this message
1620
- </button>
1621
- </div>
1622
- )}
1623
-
1624
- {/* Only rendered once this session actually has a usable signing/encryption key - see
1625
- `assembleForSend()`'s own doc comment for why signing rarely shows today (no signing
1626
- certificate exists until RFC 8823 ACME enrollment lands). */}
1627
- {unlockedKeys?.signingPrivateKey && (
1628
- <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
1629
- <input type="checkbox" checked={signEnabled} onChange={(e) => setSignEnabled(e.target.checked)} />
1630
- Digitally sign this message
1631
- </label>
1632
- )}
1633
- {unlockedKeys?.encryptionPrivateKey && (
1634
- <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
1635
- <input type="checkbox" checked={encryptRequested} onChange={(e) => setEncryptRequested(e.target.checked)} />
1636
- Encrypt this message
1637
- </label>
1638
- )}
1639
-
1640
- <div className="shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border">
1641
- <div className="flex items-center rounded-pill bg-primary text-white overflow-hidden">
1642
- <button
1643
- type="button"
1644
- onClick={() => void submit(false)}
1645
- disabled={!draft || sending || closing || !cryptoContextReady}
1646
- 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"
1647
- >
1648
- {sending ? "Sending…" : "Send"}
1649
- </button>
1650
- <button
1651
- ref={scheduleButtonRef}
1652
- type="button"
1653
- aria-label="Send later"
1654
- aria-haspopup="true"
1655
- aria-expanded={schedulePickerOpen}
1656
- disabled={!draft || sending || closing || !cryptoContextReady}
1657
- onClick={() => setSchedulePickerOpen((o) => !o)}
1658
- className="py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
1659
- >
1660
- <HiOutlineChevronDown size={14} />
1661
- </button>
1662
- </div>
1663
- {schedulePickerOpen && (
1664
- <ScheduleSendPicker
1665
- anchorRef={scheduleButtonRef}
1666
- onClose={() => setSchedulePickerOpen(false)}
1667
- onSchedule={handleScheduleSend}
1668
- />
1669
- )}
1670
-
1671
- <label
1672
- aria-label="Attach files"
1673
- title="Attach files"
1674
- 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"
1675
- >
1676
- <HiOutlinePaperClip size={18} />
1677
- <input type="file" multiple disabled={!draft} onChange={handleFilesSelected} className="sr-only" />
1678
- </label>
1679
-
1680
- <span role="status" aria-live="polite" className="ml-auto text-xs text-text-muted">
1681
- {SAVE_STATUS_LABEL[saveStatus]}
1682
- </span>
1683
-
1684
- <button
1685
- type="button"
1686
- aria-label="Discard draft"
1687
- title="Discard draft"
1688
- onClick={handleDiscard}
1689
- disabled={sending || closing}
1690
- 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"
1691
- >
1692
- <HiOutlineTrash size={18} />
1693
- </button>
1694
- </div>
1695
- </div>
1696
- </div>
1697
- {discardModal}
1698
- </>
1699
- );
1700
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ChangeEvent, useEffect, useRef, useState } from "react";
6
+ import {
7
+ HiOutlineArrowsPointingIn,
8
+ HiOutlineArrowsPointingOut,
9
+ HiOutlineChevronDown,
10
+ HiOutlineLockClosed,
11
+ HiOutlineMinus,
12
+ HiOutlinePaperClip,
13
+ HiOutlineTrash,
14
+ HiOutlineXMark,
15
+ } from "react-icons/hi2";
16
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
17
+ import {
18
+ Attachment,
19
+ ComposeRecipientInput,
20
+ Mailbox,
21
+ Message,
22
+ assembleDraft,
23
+ assembleDraftRaw,
24
+ attachmentContentUrl,
25
+ createDraft,
26
+ deleteMessage,
27
+ getMailbox,
28
+ getMessage,
29
+ listFolders,
30
+ listMailboxes,
31
+ sendMessage,
32
+ setMessageRequestReceipt,
33
+ uploadAttachment,
34
+ } from "@rapidmx/react-shared/mail/mailApi.js";
35
+ import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
36
+ import { describeSendFailure } from "@rapidmx/react-shared/mail/sendFailure.js";
37
+ import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
38
+ import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
39
+ import { decideMessageEncryption, resolveRecipientEncryption, RecipientEncryptionStatus } from "@rapidmx/react-shared/crypto/composeSecurity.js";
40
+ import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
41
+ import { EncryptionPolicy, findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
42
+ import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
43
+ import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
44
+ import type { ProtectedHeaders } from "@rapidmx/react-shared/crypto/smimeMessage.js";
45
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
46
+ import { useCompose, type ComposeSession } from "./ComposeContext.js";
47
+ import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
48
+ import { SHORTCUTS, ShortcutDef } from "../../../keyboard/keymap.js";
49
+ import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
50
+ import { useShortcut } from "../../../keyboard/useShortcut.js";
51
+ import { isSigningOut, registerComposeFlush } from "./composeFlushRegistry.js";
52
+ import { markComposePhase } from "./composePerf.js";
53
+ import RecipientInput from "./RecipientInput.js";
54
+ import { parseRecipientList } from "./recipients.js";
55
+ import RichTextEditor from "./RichTextEditor.js";
56
+ import ScheduleSendPicker from "./ScheduleSendPicker.js";
57
+ import SendFailureAlert from "./SendFailureAlert.js";
58
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
59
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
60
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
61
+
62
+ export interface ComposeWindowProps {
63
+ session: ComposeSession;
64
+ onClose: () => void;
65
+ onToggleMinimize: () => void;
66
+ /** The signed-in user - identifies their own ("primary") mailbox, the default From when the session
67
+ * doesn't name a mailbox. */
68
+ userUid?: string;
69
+ /** The caller holds a trusted (admin) role - every mailbox is writable, so no per-mailbox access checks. */
70
+ trusted?: boolean;
71
+ /** How long (ms) after the last edit the draft is autosaved. Defaults to `DEFAULT_AUTOSAVE_DELAY_MS`;
72
+ * only overridden by tests. */
73
+ autosaveDelayMs?: number;
74
+ /** Backoff (ms) between automatic retries of a failed mailbox/encryption-policy load, one entry per retry.
75
+ * Defaults to `DEFAULT_CRYPTO_RETRY_DELAYS_MS`; only overridden by tests. */
76
+ cryptoRetryDelaysMs?: number[];
77
+ }
78
+
79
+ /** Debounce between the last edit and the draft autosave. */
80
+ export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
81
+
82
+ /** Automatic retries of a failed mailbox/encryption-policy load; after the last one only "Retry" tries again. */
83
+ export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000];
84
+
85
+ /** Signed/encrypted bodies are built client-side from the editor's HTML verbatim, so an inline image
86
+ * (an uploaded attachment previewed via its content URL, or a `cid:` reference) would never be carried -
87
+ * only `assembleDraft()`'s server-side plaintext path rewrites those into real MIME parts. */
88
+ const INLINE_IMAGE_PATTERN = /<img\b[^>]*\bsrc\s*=\s*["']?(?:cid:|[^"'\s>]*\/mail\/attachments\/[^"'\s>]+\/content)/i;
89
+
90
+ const KEYS_LOCKED_SIGN_MESSAGE =
91
+ "This message can't be signed right now - your signing key is locked or your mailbox details couldn't be loaded. Unlock and send again, or send it without signing.";
92
+ const KEYS_LOCKED_ENCRYPT_MESSAGE =
93
+ "This message can't be encrypted right now - your encryption key is locked. Unlock and send again, or send it without encryption.";
94
+ const POLICY_UNAVAILABLE_MESSAGE =
95
+ "Your encryption settings couldn't be loaded, so this message can't be encrypted right now. Try again, or send it without encryption.";
96
+ const BCC_ENCRYPTED_MESSAGE = "Bcc recipients can't be used with encrypted messages. Remove Bcc recipients or turn off encryption.";
97
+ const LOOKUP_UNAVAILABLE_MESSAGE =
98
+ "Your recipients' encryption keys couldn't be checked, so this message might go out unencrypted when it should be encrypted. Try again, or send it without encryption.";
99
+ /** Thrown by `deleteDraft()` when the server copy is no longer this window's draft (sent or scheduled elsewhere). */
100
+ const NO_LONGER_A_DRAFT_MESSAGE = "This message was already sent or scheduled from another window, so it wasn't deleted.";
101
+
102
+ /** The server refuses signed/encrypted mail whose From display name differs from its own safe form, which drops a
103
+ * name containing `@` (or a look-alike) or a line break. */
104
+ const UNSAFE_DISPLAY_NAME_PATTERN = /[@@﹫\r\n]/;
105
+
106
+ /** Whether `fresh` (a re-read of a draft) is still an unsent draft in `folderUid`: never delete a message another
107
+ * window has since sent, scheduled, or started sending. */
108
+ function isStillDraft(fresh: Message, folderUid: string): boolean {
109
+ return fresh.folderUid === folderUid && !fresh.scheduledSendTime && !fresh.scheduledSendLeaseExpiresAt && !fresh.scheduledSendRelayedAt;
110
+ }
111
+
112
+ /** Any tier of `policy` auto-encrypts - only then can a message with recipients still to be added turn out encrypted
113
+ * without the user asking. */
114
+ function policyCanAutoEncrypt(policy: EncryptionPolicy): boolean {
115
+ return [policy.encryptSameOrg, policy.encryptFederated, policy.encryptExternal].includes("automatic");
116
+ }
117
+
118
+ const SAVE_STATUS_LABEL = { idle: "", saving: "Saving…", saved: "Draft saved", error: "Couldn't save draft" } as const;
119
+
120
+ const DISCARD_TITLE = "Discard this draft?";
121
+ const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so closing this window discards what you've written.";
122
+ const CHECKING_CLOSE_MESSAGE =
123
+ "This message may be encrypted, and that's still being checked, so it can't be saved as a draft yet. Keep editing and close again in a moment, or discard it.";
124
+ const CRYPTO_UNAVAILABLE_MESSAGE =
125
+ "Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
126
+ const NO_RECIPIENTS_CLOSE_MESSAGE =
127
+ "This message may be encrypted once its recipients are known, so it isn't saved as a draft until you add them. Add a recipient and close again, or discard it.";
128
+
129
+ const SEND_FAILED_TITLE = "This message wasn't sent";
130
+ function sendFailedCloseMessage(reason: string): string {
131
+ return `Sending it failed (${reason.replace(/\.$/, "")}). Closing keeps it as a draft, unsent. Keep editing to try again, or discard it.`;
132
+ }
133
+
134
+ /** The confirmation shown before a Close/Discard throws content away (or when a Close couldn't save it). */
135
+ interface ClosePrompt {
136
+ title: string;
137
+ message: string;
138
+ /** Also offer "Retry" for loading the encryption settings. */
139
+ retry?: boolean;
140
+ /** Also offer "Close, keep draft": a failed send's close prompt, where closing keeps the unsent draft as it is. */
141
+ closeAnyway?: boolean;
142
+ }
143
+
144
+ interface SecurityBlock {
145
+ message: string;
146
+ overrideLabel: string;
147
+ }
148
+
149
+ const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
150
+ const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
151
+
152
+ const MIN_WIDTH = 320;
153
+ const MIN_HEIGHT = 320;
154
+ // Kept well clear of the viewport edge, not flush against it — this is a resize *ceiling* (the drag
155
+ // handles below clamp to it), not the "expanded" preset's own size, which stays a plain `85vh`
156
+ // Tailwind class precisely so it never needs `window.innerHeight` at all: that'd have to be read at
157
+ // module-evaluation time to live alongside these other constants, and `window` doesn't exist yet
158
+ // during this app's SSR pass (see `ReactRoute`) — referencing it here would crash every page render,
159
+ // not just this component's.
160
+ const VIEWPORT_MARGIN = 24;
161
+
162
+ interface Size {
163
+ width: number;
164
+ height: number;
165
+ }
166
+
167
+ type ResizeEdge = "left" | "top" | "corner";
168
+
169
+ /** A To/Cc/Bcc field's recipients - see `recipients.ts` for the format. */
170
+ function parseAddresses(value: string): ComposeRecipientInput[] {
171
+ return parseRecipientList(value);
172
+ }
173
+
174
+ function HeaderButton({
175
+ label,
176
+ onClick,
177
+ icon: Icon,
178
+ disabled,
179
+ shortcut,
180
+ }: {
181
+ label: string;
182
+ onClick: () => void;
183
+ icon: React.ComponentType<{ size?: number }>;
184
+ disabled?: boolean;
185
+ /** The keyboard shortcut that does the same, named in the tooltip and `aria-keyshortcuts`. */
186
+ shortcut?: ShortcutDef;
187
+ }) {
188
+ const env = useKeyEnvironment();
189
+ return (
190
+ <button
191
+ type="button"
192
+ aria-label={label}
193
+ title={shortcut ? withHint(label, shortcut, env) : label}
194
+ aria-keyshortcuts={shortcut ? ariaKeyShortcuts(shortcut, env) : undefined}
195
+ onClick={onClick}
196
+ disabled={disabled}
197
+ 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"
198
+ >
199
+ <Icon size={14} />
200
+ </button>
201
+ );
202
+ }
203
+
204
+ /**
205
+ * A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
206
+ * navigating to a dedicated page (see `ComposeContext.tsx`'s doc comment for why: a full-page compose
207
+ * form meant losing your place in the inbox/calendar/contacts view behind it, and every navigation to
208
+ * `/compose` re-ran this whole app's full mailbox/folder resolution waterfall from scratch). Owns its
209
+ * own draft lifecycle end to end (unlike the old page, which read the Drafts folder from `MailShell`'s
210
+ * context — this window can be opened from apps that never mount `MailShell` at all, e.g. Contacts'
211
+ * "Email" action, so it resolves its own Drafts folder from just a `mailboxUid`).
212
+ *
213
+ * Below the `md` breakpoint, a non-minimized session instead renders as a full-screen sheet (there's no
214
+ * room for a floating window, and the click-drag resize handles below become meaningless full-screen) —
215
+ * see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
216
+ * once.
217
+ */
218
+ export default function ComposeWindow({
219
+ session,
220
+ onClose,
221
+ onToggleMinimize,
222
+ userUid,
223
+ trusted,
224
+ autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS,
225
+ cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS,
226
+ }: ComposeWindowProps) {
227
+ const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late } = session;
228
+ // What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
229
+ // only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
230
+ const baselineTo = late?.to ?? initialTo;
231
+ const baselineCc = late?.cc ?? initialCc;
232
+ useEffect(() => {
233
+ markComposePhase(id, "shell");
234
+ markComposePhase(id, "chunk");
235
+ }, []);
236
+ // The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
237
+ // compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
238
+ // Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
239
+ const [fromMailboxUid, setFromMailboxUid] = useState<string | undefined>(session.mailboxUid);
240
+ const mailboxUid = fromMailboxUid;
241
+ const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
242
+ // Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
243
+ // Drafts folder - so once anything has been uploaded the sender can no longer be switched.
244
+ const [hasUploads, setHasUploads] = useState(false);
245
+ const isMobile = useIsMobile();
246
+ const { requestUnlock } = useUnlockPrompt();
247
+ const { openCompose } = useCompose();
248
+ const keyEnv = useKeyEnvironment();
249
+ // Bumped after a successful on-demand unlock to force a re-render - `getUnlockedKeys()` below is a
250
+ // plain read from a module-level store, not React state, so nothing else would pick up the change.
251
+ const [, setUnlockRefresh] = useState(0);
252
+
253
+ const windowRef = useRef<HTMLDivElement>(null);
254
+ const [draftsFolderUid, setDraftsFolderUid] = useState<string | undefined>();
255
+ const [folderError, setFolderError] = useState<string | null>(null);
256
+ const [draft, setDraft] = useState<Message | null>(null);
257
+ // Drafts replaced by a From switch, awaiting deletion until their replacement has actually been created.
258
+ const supersededDraftsRef = useRef<Message[]>([]);
259
+ const [draftError, setDraftError] = useState<string | null>(null);
260
+ const [expanded, setExpanded] = useState(false);
261
+ const [manualSize, setManualSize] = useState<Size | null>(null);
262
+ const [to, setTo] = useState(initialTo ?? "");
263
+ const [cc, setCc] = useState(initialCc ?? "");
264
+ const [bcc, setBcc] = useState("");
265
+ const [showCcBcc, setShowCcBcc] = useState(!!initialCc);
266
+ const [subject, setSubject] = useState(initialSubject ?? "");
267
+ const [html, setHtml] = useState("");
268
+ const [contentReady, setContentReady] = useState(false);
269
+ const [attachments, setAttachments] = useState<Attachment[]>([]);
270
+ const [attachError, setAttachError] = useState<string | null>(null);
271
+ const [sendError, setSendError] = useState<string | null>(null);
272
+ // The technical facts behind `sendError` (per-recipient SMTP results, a transport error), when the server gave any.
273
+ const [sendDetails, setSendDetails] = useState<string[]>([]);
274
+ const [sending, setSending] = useState(false);
275
+ const [requestReceipt, setRequestReceipt] = useState(false);
276
+ const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
277
+ const scheduleButtonRef = useRef<HTMLButtonElement>(null);
278
+ const [mailbox, setMailbox] = useState<Mailbox | null>(null);
279
+ const [encryptionPolicy, setEncryptionPolicy] = useState<EncryptionPolicy | null>(null);
280
+ // Defaults off when replying to a detected mailing list (spec's own "Mailing lists" note under
281
+ // Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
282
+ // `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
283
+ // still turn it back on; this only changes the default.
284
+ const [signEnabled, setSignEnabled] = useState(!suppressSigning);
285
+ // A reply to or forward of an encrypted message starts (and, after a From switch, restarts) with encryption
286
+ // requested - see `OpenComposeInput.encrypt`.
287
+ const [encryptRequested, setEncryptRequested] = useState(!!initialEncrypt);
288
+ const [encryptionBlocked, setEncryptionBlocked] = useState<RecipientEncryptionStatus[] | null>(null);
289
+ // Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
290
+ // recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
291
+ // just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
292
+ // never cleared, so switching focus between To/Cc/Bcc repeatedly doesn't re-trigger lookups.
293
+ const [recipientStatuses, setRecipientStatuses] = useState<Record<string, RecipientEncryptionStatus>>({});
294
+ // Addresses whose lookup is on the wire (so the blur handlers and the prefilled-recipient effect don't
295
+ // look one up twice), and a counter bumped by a From switch so a lookup for the previous sender is dropped.
296
+ const lookupsInFlightRef = useRef(new Set<string>());
297
+ const discoveryGenerationRef = useRef(0);
298
+ // A failed lookup stores no status (it would read as "no key" and let a message that must be encrypted autosave
299
+ // or send as plaintext): it's retried with the same backoff as the mailbox/policy load, and once those retries
300
+ // run out the address is "exhausted" - shown as a check that couldn't be done, with Retry.
301
+ const lookupFailuresRef = useRef(new Map<string, number>());
302
+ const lookupRetryTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
303
+ const [lookupRetryToken, setLookupRetryToken] = useState(0);
304
+ const [exhaustedLookups, setExhaustedLookups] = useState<Record<string, true>>({});
305
+ // A send refused because signing/encryption was wanted but can't happen right now (keys locked, the
306
+ // encryption policy/mailbox couldn't be loaded, or Bcc recipients on an encrypted message) - never a
307
+ // silent downgrade to plaintext; the user has to explicitly pick the override.
308
+ const [securityBlock, setSecurityBlock] = useState<SecurityBlock | null>(null);
309
+ // The schedule time of the send that got blocked (undefined for an immediate send), so the banners'
310
+ // override buttons replay the same kind of send the user originally asked for.
311
+ const [blockedScheduleIso, setBlockedScheduleIso] = useState<string | undefined>();
312
+ // Which crypto toggles were ever shown during this compose session (per sending mailbox). Keys that
313
+ // lock after that point must block the send rather than quietly dropping the signature/encryption.
314
+ const offeredCryptoRef = useRef({ sign: false, encrypt: false });
315
+
316
+ // Draft autosave / discard bookkeeping.
317
+ const [seededHtml, setSeededHtml] = useState<string | undefined>();
318
+ const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
319
+ const [closePrompt, setClosePrompt] = useState<ClosePrompt | null>(null);
320
+ /** A Close (saving) or Discard (deleting) is on the wire - the window closes once it succeeds. */
321
+ const [closing, setClosing] = useState(false);
322
+ const [discardError, setDiscardError] = useState<string | null>(null);
323
+ /** Why the last save failed, cleared by the next successful one. */
324
+ const saveErrorRef = useRef<string | null>(null);
325
+ /** `<draftUid>:<content key>` of the last successful save. */
326
+ const lastSavedRef = useRef<string | null>(null);
327
+ /** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
328
+ const pendingSaveRef = useRef(false);
329
+ /** Mirrors `sending` for the page-leave/sign-out flushes, which run outside React's render cycle. */
330
+ const sendingRef = useRef(false);
331
+ /** The latest save request, if any - each save is chained after the one before it. Always settles (never
332
+ * rejects), to the saved message, or `undefined` when that save failed. */
333
+ const saveInFlightRef = useRef<Promise<Message | undefined>>(Promise.resolve(undefined));
334
+ /** Set once the window has been sent/closed/discarded - nothing more gets autosaved after that. */
335
+ const finishedRef = useRef(false);
336
+
337
+ // A mailbox with no keys enrolled yet just means sign/encrypt stay unavailable for this compose session -
338
+ // encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
339
+ // `cryptoContextReady` (below) gates Send/Send-later until both calls have settled either way, so a
340
+ // send that happens to race this fetch can't silently skip encryption the spec says should apply.
341
+ // A failed load is retried with backoff (`cryptoRetryDelaysMs`), then on demand ("Retry"): until both have
342
+ // loaded, whether this message must be encrypted is unknown, so it isn't autosaved (see `autosaveSuppressed`).
343
+ const [cryptoContextReady, setCryptoContextReady] = useState(false);
344
+ const [cryptoLoadFailed, setCryptoLoadFailed] = useState(false);
345
+ const [cryptoRetryToken, setCryptoRetryToken] = useState(0);
346
+
347
+ useEffect(() => {
348
+ let cancelled = false;
349
+ listMailboxes({ limit: 100 })
350
+ .then((result) => {
351
+ if (cancelled) {
352
+ return;
353
+ }
354
+ setMailboxes(result);
355
+ // Never default to a mailbox already known to be view-only (see the effect below for one
356
+ // that turns out to be).
357
+ const candidates = result.filter((mb) => peekMailboxWritability(mb, userUid, trusted) !== false);
358
+ setFromMailboxUid((current) => current ?? (candidates.find((mb) => mb.ownerUserUid === userUid) ?? candidates[0])?.uid);
359
+ })
360
+ .catch((err) => {
361
+ // Only fatal when there's no mailbox to fall back on - a session that already names one just
362
+ // loses the From picker.
363
+ if (!cancelled && !session.mailboxUid) {
364
+ setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
365
+ }
366
+ });
367
+ return () => {
368
+ cancelled = true;
369
+ };
370
+ }, []);
371
+
372
+ useEffect(() => {
373
+ if (!mailboxUid) {
374
+ return;
375
+ }
376
+ let cancelled = false;
377
+ let timer: ReturnType<typeof setTimeout> | undefined;
378
+ // A retry only fetches what's still missing.
379
+ let mailboxLoaded = mailbox?.uid === mailboxUid;
380
+ let policyLoaded = !!encryptionPolicy;
381
+ let retries = 0;
382
+ setCryptoContextReady(false);
383
+ setCryptoLoadFailed(false);
384
+ async function load() {
385
+ await Promise.all([
386
+ mailboxLoaded ||
387
+ getMailbox(mailboxUid!).then(
388
+ (result) => {
389
+ mailboxLoaded = true;
390
+ if (!cancelled) {
391
+ setMailbox(result);
392
+ }
393
+ },
394
+ () => undefined,
395
+ ),
396
+ policyLoaded ||
397
+ getEncryptionPolicy().then(
398
+ (result) => {
399
+ policyLoaded = true;
400
+ if (!cancelled) {
401
+ setEncryptionPolicy(result);
402
+ }
403
+ },
404
+ () => undefined,
405
+ ),
406
+ ]);
407
+ if (cancelled) {
408
+ return;
409
+ }
410
+ const failed = !mailboxLoaded || !policyLoaded;
411
+ setCryptoContextReady(true);
412
+ setCryptoLoadFailed(failed);
413
+ if (failed && retries < cryptoRetryDelaysMs.length) {
414
+ timer = setTimeout(() => void load(), cryptoRetryDelaysMs[retries++]);
415
+ }
416
+ }
417
+ void load();
418
+ return () => {
419
+ cancelled = true;
420
+ clearTimeout(timer);
421
+ };
422
+ }, [mailboxUid, cryptoRetryToken]);
423
+
424
+ useEffect(() => {
425
+ if (!mailboxUid) {
426
+ return;
427
+ }
428
+ // Cancellable so a slow response for a mailbox the user has since switched away from can't point
429
+ // the new draft at the wrong mailbox's Drafts folder.
430
+ let cancelled = false;
431
+ listFolders(mailboxUid)
432
+ .then((folders) => {
433
+ if (!cancelled) {
434
+ setDraftsFolderUid(folders.find((f) => f.type === "drafts")?.uid);
435
+ }
436
+ })
437
+ .catch((err) => {
438
+ if (!cancelled) {
439
+ setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder.");
440
+ }
441
+ });
442
+ return () => {
443
+ cancelled = true;
444
+ };
445
+ }, [mailboxUid]);
446
+
447
+ /**
448
+ * Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
449
+ * caching the result so the small per-recipient indicator below the recipient fields can update
450
+ * without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
451
+ * and not the same lookup `assembleForSend()` performs again at send time (deliberately: this is a
452
+ * best-effort UI hint, not something a stale value here should be trusted to skip at send time).
453
+ * A mailbox/policy fetch that hasn't resolved yet just leaves that address with no indicator - the effect
454
+ * below runs it again for every current recipient once both have loaded. A failed lookup leaves it without
455
+ * a status too, and is retried after `cryptoRetryDelaysMs` (then only on blur, Close or Retry).
456
+ *
457
+ * The statuses also drive autosave: a recipient without one yet (lookup pending, failed or never run) or a
458
+ * decision that would auto-encrypt keeps the plaintext draft save off (see `autosaveSuppressed`).
459
+ */
460
+ function checkRecipientDiscovery(addresses: ComposeRecipientInput[]) {
461
+ if (!mailbox || !encryptionPolicy) {
462
+ return;
463
+ }
464
+ const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
465
+ const generation = discoveryGenerationRef.current;
466
+ const inFlight = lookupsInFlightRef.current;
467
+ const unchecked = addresses.filter((r) => !(r.address in recipientStatuses) && !inFlight.has(r.address));
468
+ for (const recipient of unchecked) {
469
+ inFlight.add(recipient.address);
470
+ void lookupKeys(mailbox.uid, recipient.address)
471
+ .then(
472
+ (lookup) => ({ lookup }),
473
+ () => null,
474
+ )
475
+ .then((result) => {
476
+ inFlight.delete(recipient.address);
477
+ if (generation !== discoveryGenerationRef.current) {
478
+ return;
479
+ }
480
+ if (!result) {
481
+ noteLookupFailed(recipient.address);
482
+ return;
483
+ }
484
+ lookupFailuresRef.current.delete(recipient.address);
485
+ const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
486
+ setRecipientStatuses((prev) => ({ ...prev, [recipient.address]: status }));
487
+ });
488
+ }
489
+ }
490
+
491
+ /** Schedules the next automatic retry of a failed lookup, or marks the address exhausted once they've run out. */
492
+ function noteLookupFailed(address: string) {
493
+ const failures = (lookupFailuresRef.current.get(address) ?? 0) + 1;
494
+ lookupFailuresRef.current.set(address, failures);
495
+ if (failures > cryptoRetryDelaysMs.length) {
496
+ setExhaustedLookups((prev) => ({ ...prev, [address]: true }));
497
+ return;
498
+ }
499
+ const timer = setTimeout(() => {
500
+ lookupRetryTimersRef.current.delete(timer);
501
+ setLookupRetryToken((n) => n + 1);
502
+ }, cryptoRetryDelaysMs[failures - 1]);
503
+ lookupRetryTimersRef.current.add(timer);
504
+ }
505
+
506
+ useEffect(
507
+ () => () => {
508
+ for (const timer of lookupRetryTimersRef.current) {
509
+ clearTimeout(timer);
510
+ }
511
+ },
512
+ [],
513
+ );
514
+
515
+ // Prefilled recipients (a reply's To/Cc) are never blurred, and a blur before the mailbox/policy loaded
516
+ // was ignored - look every current recipient up as soon as both are available (and again on a lookup retry).
517
+ useEffect(() => {
518
+ checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
519
+ }, [mailbox, encryptionPolicy, lookupRetryToken]);
520
+
521
+ // Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
522
+ // plus any quoted original message (laid out by `buildComposeBodyHtml()`: an empty first paragraph for the
523
+ // caret, then the signature, then the quote), before `RichTextEditor` ever mounts (gated by `contentReady`
524
+ // below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
525
+ // content and never re-syncs from a later prop change, so this has to resolve before that first
526
+ // mount, not after. A signature-list failure is best-effort, same as every other supplementary,
527
+ // non-blocking fetch in this codebase (e.g. `ConversationList`'s own child-message fetch) — no
528
+ // signature is a completely legitimate outcome, so this falls back to just the quoted content
529
+ // (if any) rather than surfacing an error over what's a cosmetic nicety.
530
+ //
531
+ // A reply or forward opens before its quoted original has been fetched (`quotePending`, see
532
+ // `OpenComposeInput.pending`). The body is seeded as soon as the signature is here, without the quote, so the editor is usable
533
+ // at once however slow the original is to come; the quote is added at the end of the editor's document when it arrives
534
+ // (`appendHtml` below) - the same place, under the signature, that a body seeded with it would have had it.
535
+ const [defaultSignature, setDefaultSignature] = useState<{ html?: string } | undefined>();
536
+ useEffect(() => {
537
+ // Fetched once, for whichever mailbox is the sender when compose opens - switching From later must not
538
+ // overwrite what the user has already written (the editor itself never re-syncs).
539
+ if (!mailboxUid || contentReady || defaultSignature) {
540
+ return;
541
+ }
542
+ listMailSignatures(mailboxUid)
543
+ .then((signatures) => {
544
+ const signature = signatures.find((s) =>
545
+ signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward,
546
+ );
547
+ setDefaultSignature({ html: signature?.contentHtml });
548
+ })
549
+ .catch(() => setDefaultSignature({}));
550
+ }, [mailboxUid, signatureContext]);
551
+ const [seededWithoutQuote, setSeededWithoutQuote] = useState(false);
552
+ useEffect(() => {
553
+ if (contentReady || !defaultSignature) {
554
+ return;
555
+ }
556
+ const seeded = buildComposeBodyHtml(defaultSignature.html, quotePending ? undefined : initialQuotedHtml);
557
+ setHtml(seeded);
558
+ setSeededHtml(seeded);
559
+ setSeededWithoutQuote(!!quotePending);
560
+ setContentReady(true);
561
+ markComposePhase(id, "body");
562
+ }, [defaultSignature, quotePending, initialQuotedHtml]);
563
+ /** What the editor adds to the end of its document once the quote a reply was opened without has arrived. */
564
+ const lateQuoteHtml =
565
+ seededWithoutQuote && !quotePending && initialQuotedHtml && defaultSignature
566
+ ? buildComposeBodyHtml(defaultSignature.html, initialQuotedHtml).slice(buildComposeBodyHtml(defaultSignature.html) === "" ? "<p></p>".length : buildComposeBodyHtml(defaultSignature.html).length)
567
+ : undefined;
568
+
569
+ useEffect(() => {
570
+ if (!mailboxUid || !draftsFolderUid || draft) {
571
+ return;
572
+ }
573
+ let cancelled = false;
574
+ // `session.threading` is what makes a reply a reply: the server writes it into the relayed MIME's
575
+ // `In-Reply-To`/`References` and groups the message into the thread being answered. Recorded on
576
+ // every draft this window creates, including the replacement one a From switch makes - the thread
577
+ // doesn't change because the sending mailbox did.
578
+ createDraft(mailboxUid, draftsFolderUid, session.threading)
579
+ .then((created) => {
580
+ if (cancelled) {
581
+ // The sender changed while this was in flight - discard the now-orphaned draft.
582
+ void deleteMessage(created.uid, created.version).catch(() => undefined);
583
+ return;
584
+ }
585
+ setDraft(created);
586
+ markComposePhase(id, "draft");
587
+ // Only now that the replacement exists is it safe to discard the draft(s) a From switch
588
+ // superseded - if creating this one had failed, the earlier draft is still there.
589
+ for (const superseded of supersededDraftsRef.current.splice(0)) {
590
+ void deleteSupersededDraft(superseded);
591
+ }
592
+ })
593
+ .catch((err) => {
594
+ if (!cancelled) {
595
+ setDraftError(err instanceof ApiRequestError ? err.message : "Could not start a new draft.");
596
+ }
597
+ });
598
+ return () => {
599
+ cancelled = true;
600
+ };
601
+ }, [mailboxUid, draftsFolderUid, draft, session.threading]);
602
+
603
+ // Drafts a From switch superseded are otherwise only deleted once their replacement exists - closing the
604
+ // window (or sending, which closes it) first would orphan them.
605
+ useEffect(
606
+ () => () => {
607
+ for (const superseded of supersededDraftsRef.current.splice(0)) {
608
+ void deleteSupersededDraft(superseded);
609
+ }
610
+ },
611
+ [],
612
+ );
613
+
614
+ /**
615
+ * Deletes a draft a From switch replaced. An autosave of it may still be on the wire - it bumps the
616
+ * draft's version (see `saveDraftNow()`, which keeps `superseded.version` current while it's queued), so
617
+ * the delete waits for it and uses its version; a delete that still fails (e.g. another save of it landed
618
+ * meanwhile) is retried once with the version the server currently holds - unless it's no longer a draft
619
+ * (another window sent or scheduled it), which is left alone.
620
+ * Best-effort, never rejects.
621
+ */
622
+ async function deleteSupersededDraft(superseded: Message): Promise<void> {
623
+ const saved = await saveInFlightRef.current;
624
+ // Once it's out of `supersededDraftsRef` a landing save can't update `superseded.version` any more.
625
+ const version = saved?.uid === superseded.uid ? saved.version : superseded.version;
626
+ try {
627
+ await deleteMessage(superseded.uid, version);
628
+ } catch {
629
+ try {
630
+ const fresh = await getMessage(superseded.uid);
631
+ if (isStillDraft(fresh, superseded.folderUid)) {
632
+ await deleteMessage(fresh.uid, fresh.version);
633
+ }
634
+ } catch {
635
+ // Already gone, or the server is unreachable - nothing more to do from here.
636
+ }
637
+ }
638
+ }
639
+
640
+ // The From picker lists every mailbox straight away and drops view-only ones as each check answers - only
641
+ // mailboxes the caller can create a draft in are worth offering.
642
+ const writability = useMailboxWritability(mailboxes, userUid, trusted);
643
+ const fromOptions = mailboxes.filter((mb) => mb.uid === mailboxUid || writability[mb.uid] !== false);
644
+
645
+ // The sender turned out to be view-only (e.g. a reply to a message in a mailbox shared read-only, or a
646
+ // first-listed shared mailbox): switch to one the caller can actually send from, preferring their own.
647
+ const fromIsViewOnly = !!mailboxUid && writability[mailboxUid] === false;
648
+ useEffect(() => {
649
+ if (!fromIsViewOnly || hasUploads) {
650
+ return;
651
+ }
652
+ const ordered = [...mailboxes.filter((mb) => mb.ownerUserUid === userUid), ...mailboxes.filter((mb) => mb.ownerUserUid !== userUid)];
653
+ const alternative = ordered.find((mb) => writability[mb.uid] === true) ?? ordered.find((mb) => writability[mb.uid] === undefined);
654
+ if (alternative) {
655
+ handleFromChange(alternative.uid);
656
+ }
657
+ }, [fromIsViewOnly, writability]);
658
+
659
+ /** Switches the sending mailbox: supersedes the current (unsent, upload-free) draft and resets the
660
+ * per-mailbox state, so the effects above start a fresh draft and crypto context in the new mailbox -
661
+ * the old draft is deleted only once the new one has been created. Recipients, subject, and body are
662
+ * kept. */
663
+ function handleFromChange(nextMailboxUid: string) {
664
+ if (nextMailboxUid === mailboxUid || hasUploads) {
665
+ return;
666
+ }
667
+ if (draft) {
668
+ supersededDraftsRef.current.push(draft);
669
+ }
670
+ setDraft(null);
671
+ setDraftsFolderUid(undefined);
672
+ setDraftError(null);
673
+ setFolderError(null);
674
+ setMailbox(null);
675
+ setRecipientStatuses({});
676
+ discoveryGenerationRef.current += 1;
677
+ lookupsInFlightRef.current = new Set();
678
+ lookupFailuresRef.current = new Map();
679
+ setExhaustedLookups({});
680
+ setEncryptRequested(!!initialEncrypt);
681
+ setEncryptionBlocked(null);
682
+ setSecurityBlock(null);
683
+ offeredCryptoRef.current = { sign: false, encrypt: false };
684
+ setFromMailboxUid(nextMailboxUid);
685
+ }
686
+
687
+ /**
688
+ * Click-and-drag resize, from the window's own top/left edges (and the top-left corner, for both
689
+ * at once) — the window is anchored bottom-right (see `ComposeContext.tsx`'s stack), so growing it
690
+ * always means extending up and/or left, never down/right. `onMove`/`onUp` are fresh closures
691
+ * created per drag gesture (captured over this gesture's own start position/size) rather than
692
+ * stable component-level functions, specifically so `removeEventListener` in `onUp` always removes
693
+ * the exact listener `onMove`/`onUp` themselves just added — a version defined once per render and
694
+ * reused across gestures would need extra ref-juggling to avoid removing a stale (already-replaced)
695
+ * listener instead of the current one.
696
+ */
697
+ function handleResizeStart(edge: ResizeEdge) {
698
+ return (e: React.PointerEvent) => {
699
+ e.preventDefault();
700
+ const rect = windowRef.current!.getBoundingClientRect();
701
+ const startX = e.clientX;
702
+ const startY = e.clientY;
703
+ const startWidth = rect.width;
704
+ const startHeight = rect.height;
705
+
706
+ function onMove(ev: PointerEvent) {
707
+ const maxWidth = window.innerWidth - VIEWPORT_MARGIN;
708
+ const maxHeight = window.innerHeight - VIEWPORT_MARGIN;
709
+ const width = edge === "top" ? startWidth : Math.min(Math.max(startWidth - (ev.clientX - startX), MIN_WIDTH), maxWidth);
710
+ const height = edge === "left" ? startHeight : Math.min(Math.max(startHeight - (ev.clientY - startY), MIN_HEIGHT), maxHeight);
711
+ setManualSize({ width, height });
712
+ }
713
+ function onUp() {
714
+ window.removeEventListener("pointermove", onMove);
715
+ window.removeEventListener("pointerup", onUp);
716
+ }
717
+ window.addEventListener("pointermove", onMove);
718
+ window.addEventListener("pointerup", onUp);
719
+ };
720
+ }
721
+
722
+ function toggleExpanded() {
723
+ setExpanded((v) => !v);
724
+ setManualSize(null);
725
+ }
726
+
727
+ /** Uploads `file` as an attachment on the current draft, resolving to a URL the editor can preview
728
+ * it at immediately — see `BaseMailComposeRoute.rewriteInlineImageSources()`'s doc comment for how
729
+ * that URL gets swapped for the message's real inline `cid:` reference at send time. Resolves to
730
+ * `null` (surfacing `attachError` itself, reusing the same state "Attach files" already has) rather
731
+ * than throwing: unlike "Attach files" (`disabled={!draft}`), "Insert image" in the formatting
732
+ * toolbar is only gated on the *editor* being mounted, not on the draft existing yet — those two
733
+ * things load in parallel, so a real (if narrow) window exists where the button is clickable before
734
+ * `draft` resolves, and this needs to fail gracefully rather than crash on a null draft.
735
+ */
736
+ async function handleUploadImage(file: File): Promise<string | null> {
737
+ if (!draft) {
738
+ setAttachError("Please wait for the draft to finish loading before inserting an image.");
739
+ return null;
740
+ }
741
+ try {
742
+ const attachment = await uploadAttachment(draft.uid, file);
743
+ setHasUploads(true);
744
+ void refreshDraftVersion(draft.uid);
745
+ return attachmentContentUrl(attachment.uid);
746
+ } catch (err) {
747
+ setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
748
+ return null;
749
+ }
750
+ }
751
+
752
+ /** Uploading an attachment bumps the draft's version server-side (it records `hasAttachments`), so a later
753
+ * Discard would otherwise send a stale `?version=`. Best-effort: Discard also retries with the server's
754
+ * current version. */
755
+ async function refreshDraftVersion(uid: string) {
756
+ try {
757
+ const fresh = await getMessage(uid);
758
+ setDraft((prev) => (prev?.uid === fresh.uid && fresh.version > prev.version ? { ...prev, version: fresh.version } : prev));
759
+ } catch {
760
+ // Discard's own retry covers a version this couldn't refresh.
761
+ }
762
+ }
763
+
764
+ // The "Attach files" input is itself `disabled` until `draft` resolves (see its `disabled={!draft}`
765
+ // below), so this can only ever fire once `draft` is set — no defensive null check needed, matching
766
+ // this codebase's established pattern for the same class of "always non-null by the time it's
767
+ // called" value (e.g. `ComposeToolbar.run()`). No separate empty-`files` guard either: the loop
768
+ // below is already a no-op when there's nothing to iterate.
769
+ async function handleFilesSelected(e: ChangeEvent<HTMLInputElement>) {
770
+ const files = Array.from(e.target.files ?? []);
771
+ e.target.value = "";
772
+ setAttachError(null);
773
+ for (const file of files) {
774
+ try {
775
+ const attachment = await uploadAttachment(draft!.uid, file);
776
+ setHasUploads(true);
777
+ setAttachments((prev) => [...prev, attachment]);
778
+ void refreshDraftVersion(draft!.uid);
779
+ } catch (err) {
780
+ setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
781
+ }
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Builds this draft's final body — plaintext, signed-only, or signed+encrypted per
787
+ * `specs/end-to-end_encryption.md` — then stores it via `assembleDraft()`/`assembleDraftRaw()`.
788
+ * Shared by immediate and scheduled sends (and the banners' override buttons rendered below, via
789
+ * `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
790
+ *
791
+ * Returns `"blocked"` (never throws for this case) whenever the message can't go out the way the user
792
+ * asked for — never a silent downgrade to plaintext:
793
+ * encryption was requested (or auto-applies) but not every recipient can currently be encrypted to -
794
+ * the spec's "Multiple Recipients" all-or-nothing rule (sets `encryptionBlocked`); signing/encryption was
795
+ * on offer this session but the keys have since locked, or the mailbox/encryption policy needed to decide
796
+ * couldn't be loaded (sets `securityBlock`, and re-prompts the unlock dialog when the keys are merely
797
+ * locked); or the message would be encrypted but has Bcc recipients - every recipient's certificate goes
798
+ * into one shared envelope, which would disclose the Bcc list to every other recipient, and the send API
799
+ * has no per-recipient-copy submission to avoid that (sets `securityBlock`).
800
+ * Callers must stop (not send) when they get this back. `forcePlaintext` (the user's explicit choice
801
+ * from one of those banners) skips every one of these checks and never encrypts; signing still
802
+ * happens if it's currently possible.
803
+ *
804
+ * Discovery (`lookupKeys()`) and the resulting sign/encrypt decision only run at this, the final
805
+ * pre-send step — `checkRecipientDiscovery()`'s on-blur badges are a best-effort hint only.
806
+ */
807
+ async function assembleForSend(
808
+ toRecipients: ComposeRecipientInput[],
809
+ ccRecipients: ComposeRecipientInput[],
810
+ bccRecipients: ComposeRecipientInput[],
811
+ forcePlaintext: boolean,
812
+ ): Promise<Message | "blocked"> {
813
+ // Keys are read at the moment they're needed, and again after any await: they can lock (idle
814
+ // timeout, sign-out elsewhere) mid-send, and a destroyed `UnlockedKeys` object must count as locked.
815
+ // Only reachable once a draft exists, which itself requires `mailboxUid` to have resolved.
816
+ function readKeys() {
817
+ const stored = getUnlockedKeys(mailboxUid!);
818
+ const unlocked = stored?.destroyed ? undefined : stored;
819
+ return {
820
+ unlocked,
821
+ // `mailbox` is required to build protected headers (own From address) whenever signing or
822
+ // encrypting - not just guarded on the encryption branch below, since a signed-only message
823
+ // needs it too.
824
+ canSign: signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer,
825
+ canEncryptSelf: !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer,
826
+ };
827
+ }
828
+ let keys = readKeys();
829
+ const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
830
+
831
+ // The Sign toggle was shown (and is still checked), but signing can't actually happen any more.
832
+ const signingLost = () => !forcePlaintext && signEnabled && offeredCryptoRef.current.sign && !keys.canSign;
833
+ if (signingLost()) {
834
+ return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
835
+ }
836
+
837
+ if (!forcePlaintext && !mailbox) {
838
+ // The mailbox's enrolled keys are what say whether this message could be encrypted at all - with keys
839
+ // that aren't unlocked in this session, a failed mailbox load would otherwise skip encryption silently.
840
+ return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
841
+ }
842
+
843
+ let wantEncrypt = false;
844
+ let recipientCertDers: Uint8Array[] = [];
845
+ // Entered whenever this message could be encrypted at all - including a mailbox with an enrolled
846
+ // encryption key that this session never unlocked: policy may still auto-encrypt, and that must block
847
+ // (and prompt to unlock) rather than quietly send plaintext.
848
+ if (!forcePlaintext && (keys.canEncryptSelf || offeredCryptoRef.current.encrypt || hasEnrolledEncryptionKey)) {
849
+ if (!encryptionPolicy) {
850
+ // Without it, there's no telling whether policy auto-encrypts this message.
851
+ return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
852
+ }
853
+ // Reachable only with `mailbox` loaded (checked above).
854
+ const ownPrefersMutual = mailbox!.encryptPreference?.preferEncrypt === "mutual";
855
+ const lookups = await Promise.all(
856
+ allRecipients.map((r) =>
857
+ lookupKeys(mailboxUid!, r.address).then(
858
+ (lookup) => ({ lookup }),
859
+ () => null,
860
+ ),
861
+ ),
862
+ );
863
+ keys = readKeys();
864
+ if (signingLost()) {
865
+ return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
866
+ }
867
+ // A failed lookup isn't "no key": treating it as one could send plaintext that policy would encrypt.
868
+ if (lookups.some((result) => !result) && (encryptRequested || policyCanAutoEncrypt(encryptionPolicy))) {
869
+ return blockSend(LOOKUP_UNAVAILABLE_MESSAGE, "Send without encryption", false);
870
+ }
871
+ const statuses = allRecipients.map((r, i) =>
872
+ resolveRecipientEncryption(mailbox!.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]?.lookup),
873
+ );
874
+ const decision = decideMessageEncryption(statuses);
875
+ wantEncrypt = encryptRequested || decision.autoEncrypt;
876
+ if (wantEncrypt) {
877
+ if (!keys.canEncryptSelf) {
878
+ return blockSend(KEYS_LOCKED_ENCRYPT_MESSAGE, "Send without encryption", !keys.unlocked);
879
+ }
880
+ if (!decision.canEncryptAll) {
881
+ setEncryptionBlocked(decision.blockedRecipients);
882
+ return "blocked";
883
+ }
884
+ if (bccRecipients.length > 0) {
885
+ return blockSend(BCC_ENCRYPTED_MESSAGE, "Send without encryption", false);
886
+ }
887
+ recipientCertDers = [keys.unlocked!.encryptionCertDer!, ...statuses.map((s) => fromBase64(s.encryptCert!.publicKey))];
888
+ }
889
+ }
890
+
891
+ const { canSign } = keys;
892
+ if ((wantEncrypt || canSign) && attachments.length > 0) {
893
+ // Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
894
+ // clearer explanation than that route's generic 400 rather than via a round trip.
895
+ throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
896
+ }
897
+ if ((wantEncrypt || canSign) && INLINE_IMAGE_PATTERN.test(html)) {
898
+ throw new ApiRequestError("A signed or encrypted message cannot include inline images yet - remove them before sending.", 400);
899
+ }
900
+
901
+ if (!wantEncrypt && !canSign) {
902
+ // See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
903
+ // gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
904
+ // client-side pass is done here either.
905
+ return assembleDraft(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject, html });
906
+ }
907
+
908
+ const domain = mailbox!.primarySmtpAddress.split("@")[1] ?? "localhost";
909
+ const displayName = mailbox!.displayName && !UNSAFE_DISPLAY_NAME_PATTERN.test(mailbox!.displayName) ? mailbox!.displayName : "";
910
+ const protectedHeaders: ProtectedHeaders = {
911
+ from: displayName
912
+ ? `"${displayName.replace(/"/g, '\\"')}" <${mailbox!.primarySmtpAddress}>`
913
+ : mailbox!.primarySmtpAddress,
914
+ to: toRecipients.map((r) => r.address).join(", "),
915
+ cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
916
+ date: new Date().toUTCString(),
917
+ subject,
918
+ messageId: `<${crypto.randomUUID()}@${domain}>`,
919
+ };
920
+ const bodyContentType = 'text/html; charset="utf-8"';
921
+ const signing = canSign ? { certDer: keys.unlocked!.signingCertDer!, privateKey: keys.unlocked!.signingPrivateKey! } : undefined;
922
+
923
+ // The S/MIME code (PKI.js and the ASN.1/X.509 libraries, over half a megabyte) is only needed here, to sign or
924
+ // encrypt - a plain message never loads it, and it is kept out of the compose window's own chunk.
925
+ const { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } = await import(
926
+ "@rapidmx/react-shared/crypto/smimeMessage.js"
927
+ );
928
+ const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
929
+ const mimePart = wantEncrypt
930
+ ? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
931
+ : await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing!.certDer, signing!.privateKey);
932
+ const rawMime = assembleOutboundMime(outerHeaders, mimePart);
933
+ return assembleDraftRaw(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
934
+ }
935
+
936
+ /** Records why a send was refused (see `assembleForSend()`), re-prompting the unlock dialog when the
937
+ * cause is keys that are enrolled but locked. */
938
+ function blockSend(message: string, overrideLabel: string, keysLocked: boolean): "blocked" {
939
+ setSecurityBlock({ message, overrideLabel });
940
+ if (keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
941
+ void handleUnlockForCrypto();
942
+ }
943
+ return "blocked";
944
+ }
945
+
946
+ /**
947
+ * Sends (or, given `scheduledSendTimeIso`, schedules) the draft. Same reasoning as `handleFilesSelected`
948
+ * above: Send and the "Send later" caret are both `disabled` until `draft` resolves, so this is never
949
+ * reachable with a null `draft`. A scheduled send assembles the draft first (same as an immediate send),
950
+ * then sets `scheduledSendTime` on the *freshly assembled* copy before calling `sendMessage()` — `send()`
951
+ * itself is what reads the field and defers relay into Outbox instead of sending immediately (see
952
+ * `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`). A blocked send remembers its schedule
953
+ * time so the banners' override buttons replay it as the same kind of send.
954
+ */
955
+ async function submit(forcePlaintext: boolean, scheduledSendTimeIso?: string) {
956
+ const toRecipients = parseAddresses(to);
957
+ if (toRecipients.length === 0) {
958
+ setSendError("At least one recipient is required.");
959
+ return;
960
+ }
961
+
962
+ setSending(true);
963
+ sendingRef.current = true;
964
+ setSendError(null);
965
+ setSendDetails([]);
966
+ setSecurityBlock(null);
967
+ setEncryptionBlocked(null);
968
+ pendingSaveRef.current = false;
969
+ try {
970
+ // An autosave already on the wire must never land after (and overwrite) the send's own assembly.
971
+ await saveInFlightRef.current;
972
+ const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), forcePlaintext);
973
+ if (assembled === "blocked") {
974
+ setBlockedScheduleIso(scheduledSendTimeIso);
975
+ return;
976
+ }
977
+ // The draft now holds exactly this content (possibly signed/encrypted) - don't autosave a
978
+ // plaintext copy over it if the rest of the send fails.
979
+ lastSavedRef.current = `${assembled.uid}:${contentKey}`;
980
+ const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
981
+ await sendMessage(withReceipt.uid, scheduledSendTimeIso ? { scheduledSendTime: scheduledSendTimeIso } : undefined);
982
+ finishedRef.current = true;
983
+ onClose();
984
+ } catch (err) {
985
+ // The window stays open with the draft exactly as it was; nothing here closes, discards or saves over it.
986
+ const failure = describeSendFailure(
987
+ err,
988
+ scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.",
989
+ );
990
+ setSendError(failure.message);
991
+ setSendDetails(failure.lines);
992
+ } finally {
993
+ sendingRef.current = false;
994
+ setSending(false);
995
+ }
996
+ }
997
+
998
+ function handleScheduleSend(scheduledSendTimeIso: string) {
999
+ setSchedulePickerOpen(false);
1000
+ void submit(false, scheduledSendTimeIso);
1001
+ }
1002
+
1003
+ async function handleUnlockForCrypto() {
1004
+ try {
1005
+ // Only reachable when hasEnrolledSigningKey/hasEnrolledEncryptionKey found a real enrolled key
1006
+ // in mailbox.keys (the unlock button below, or blockSend()), so neither is empty/undefined here.
1007
+ await requestUnlock(mailbox!.uid, mailbox!.keys!);
1008
+ setUnlockRefresh((n) => n + 1);
1009
+ } catch {
1010
+ // User dismissed the unlock dialog - nothing to do, the toggles below simply stay hidden.
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * Saves the window's current recipients/subject/body onto its server draft via `assembleDraft()` (the
1016
+ * same plaintext assembly a normal send uses). Chained after any save already on the wire - two
1017
+ * overlapping assemblies of the same draft race on its version and the later one is rejected - and reads
1018
+ * `latestRef` only once it actually runs, so it saves what was last typed (skipping the request when the
1019
+ * save before it already stored exactly that). Whether a save may happen at all is re-checked at that same
1020
+ * moment: while it waited, the message may have become one that must not be stored as plaintext (Encrypt
1021
+ * turned on, a recipient that auto-encrypts), or the window may have started sending or finished - then it
1022
+ * resolves `{ skipped: true }` without a request (`message` then being the previous save's result). Never
1023
+ * rejects; `message` is `undefined` when the save failed.
1024
+ */
1025
+ function saveDraftNow(): Promise<{ message?: Message; skipped?: boolean }> {
1026
+ const target = latestRef.current.draft!;
1027
+ const previous = saveInFlightRef.current;
1028
+ pendingSaveRef.current = false;
1029
+ setSaveStatus("saving");
1030
+ const outcome = (async (): Promise<{ message?: Message; skipped?: boolean }> => {
1031
+ const before = await previous;
1032
+ const current = latestRef.current;
1033
+ if (current.autosaveSuppressed || sendingRef.current || finishedRef.current) {
1034
+ setSaveStatus("idle");
1035
+ // Still the newest copy the server holds, for the version a later delete needs.
1036
+ return { skipped: true, message: before };
1037
+ }
1038
+ const savedKey = `${target.uid}:${current.contentKey}`;
1039
+ if (before?.uid === target.uid && lastSavedRef.current === savedKey) {
1040
+ setSaveStatus("saved");
1041
+ return { message: before };
1042
+ }
1043
+ try {
1044
+ const saved = await assembleDraft(target.uid, {
1045
+ to: parseAddresses(current.to),
1046
+ cc: parseAddresses(current.cc),
1047
+ bcc: parseAddresses(current.bcc),
1048
+ subject: current.subject,
1049
+ html: current.html,
1050
+ });
1051
+ lastSavedRef.current = savedKey;
1052
+ saveErrorRef.current = null;
1053
+ // A From switch may have replaced the draft while this was in flight - never resurrect it,
1054
+ // but do keep its superseded copy's version current so its pending delete still matches.
1055
+ setDraft((prev) => (prev?.uid === saved.uid ? saved : prev));
1056
+ const superseded = supersededDraftsRef.current.find((d) => d.uid === saved.uid);
1057
+ if (superseded) {
1058
+ superseded.version = saved.version;
1059
+ }
1060
+ setSaveStatus("saved");
1061
+ return { message: saved };
1062
+ } catch (err) {
1063
+ saveErrorRef.current = err instanceof ApiRequestError ? err.message : "the server couldn't be reached";
1064
+ setSaveStatus("error");
1065
+ return {};
1066
+ }
1067
+ })();
1068
+ saveInFlightRef.current = outcome.then((result) => result.message);
1069
+ return outcome;
1070
+ }
1071
+
1072
+ /** Keeps the window open after its content couldn't be saved, offering Discard or Keep editing. */
1073
+ function showSaveFailedPrompt() {
1074
+ setClosePrompt({
1075
+ title: "Couldn't save this draft",
1076
+ message: `It couldn't be saved (${saveErrorRef.current!.replace(/\.$/, "")}). Keep editing and try again, or discard it.`,
1077
+ });
1078
+ }
1079
+
1080
+ /**
1081
+ * Deletes `current` (this window's server draft) once any save already on the wire has landed, so the delete
1082
+ * carries the draft's current version. A delete rejected for a stale version (404/409: e.g. an attachment
1083
+ * upload bumped it) is retried once with the version the server holds - and a draft the server no longer has
1084
+ * counts as deleted. The retry only happens while the server copy is still an unsent draft in the same folder.
1085
+ * Rejects when the draft couldn't be deleted.
1086
+ */
1087
+ async function deleteDraft(current: Message): Promise<void> {
1088
+ const saved = await saveInFlightRef.current;
1089
+ const version = Math.max(current.version, saved?.uid === current.uid ? saved.version : 0);
1090
+ try {
1091
+ await deleteMessage(current.uid, version);
1092
+ } catch (err) {
1093
+ if (!(err instanceof ApiRequestError) || (err.status !== 404 && err.status !== 409)) {
1094
+ throw err;
1095
+ }
1096
+ const fresh = await getMessage(current.uid).catch((getErr: unknown) => {
1097
+ if (getErr instanceof ApiRequestError && getErr.status === 404) {
1098
+ return null;
1099
+ }
1100
+ throw getErr;
1101
+ });
1102
+ if (!fresh) {
1103
+ return;
1104
+ }
1105
+ // The version changed because another window sent or scheduled it - that message isn't ours to delete.
1106
+ if (!isStillDraft(fresh, current.folderUid)) {
1107
+ throw new ApiRequestError(NO_LONGER_A_DRAFT_MESSAGE, 409);
1108
+ }
1109
+ await deleteMessage(fresh.uid, fresh.version);
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * Discards the window's draft and closes it. With nothing the user would lose (a blank draft) it closes
1115
+ * straight away and deletes the draft in the background; otherwise it closes only once the draft is
1116
+ * deleted, and a delete that fails keeps the window open with the error.
1117
+ */
1118
+ async function discardNow() {
1119
+ finishedRef.current = true;
1120
+ pendingSaveRef.current = false;
1121
+ setClosePrompt(null);
1122
+ setDiscardError(null);
1123
+ const current = draft;
1124
+ if (!current || !hasUserContent) {
1125
+ if (current) {
1126
+ void deleteDraft(current).catch(() => undefined);
1127
+ }
1128
+ onClose();
1129
+ return;
1130
+ }
1131
+ setClosing(true);
1132
+ try {
1133
+ await deleteDraft(current);
1134
+ } catch (err) {
1135
+ finishedRef.current = false;
1136
+ setClosing(false);
1137
+ setDiscardError(`Couldn't discard this draft: ${err instanceof ApiRequestError ? err.message : "the server couldn't be reached."}`);
1138
+ return;
1139
+ }
1140
+ onClose();
1141
+ }
1142
+
1143
+ /** "Discard draft": confirms first whenever there's anything the user would lose. */
1144
+ function handleDiscard() {
1145
+ if (hasUserContent) {
1146
+ setClosePrompt({ title: DISCARD_TITLE, message: "This permanently deletes this draft and everything you've written in it." });
1147
+ } else {
1148
+ void discardNow();
1149
+ }
1150
+ }
1151
+
1152
+ /** "Close": keeps the draft (saving any unsaved edits first, and closing only once that save succeeded) -
1153
+ * unless there's nothing in it worth keeping, or it can't be saved because it's headed for encryption, or
1154
+ * whether it is can't be told yet (settings loading or unavailable, recipient lookups pending): those need
1155
+ * the same confirmation as a discard. A failed save keeps the window open with Discard / Keep editing.
1156
+ * Close and Discard are both disabled while a send is in progress: deleting the draft, or saving a
1157
+ * plaintext copy over the message the send just assembled, mid-send would lose or leak it. */
1158
+ async function handleClose(sendFailureAcknowledged = false) {
1159
+ if (!hasUserContent) {
1160
+ void discardNow();
1161
+ return;
1162
+ }
1163
+ if (sendError && !sendFailureAcknowledged) {
1164
+ // Closing would keep the draft and say nothing, which reads as if it had been sent.
1165
+ setClosePrompt({ title: SEND_FAILED_TITLE, message: sendFailedCloseMessage(sendError), closeAnyway: true });
1166
+ return;
1167
+ }
1168
+ if (encryptionDecided) {
1169
+ setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
1170
+ return;
1171
+ }
1172
+ if (cryptoCheckUnavailable) {
1173
+ setClosePrompt({ title: DISCARD_TITLE, message: CRYPTO_UNAVAILABLE_MESSAGE, retry: true });
1174
+ return;
1175
+ }
1176
+ if (awaitingRecipients) {
1177
+ setClosePrompt({ title: DISCARD_TITLE, message: NO_RECIPIENTS_CLOSE_MESSAGE });
1178
+ return;
1179
+ }
1180
+ if (encryptionUndetermined) {
1181
+ // A recipient typed but never blurred has no lookup running yet.
1182
+ checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
1183
+ setClosePrompt({ title: DISCARD_TITLE, message: CHECKING_CLOSE_MESSAGE });
1184
+ return;
1185
+ }
1186
+ if (!draft || !contentReady) {
1187
+ saveErrorRef.current = draftError ?? "the draft hasn't been created yet";
1188
+ showSaveFailedPrompt();
1189
+ return;
1190
+ }
1191
+ if (lastSavedRef.current !== `${draft.uid}:${contentKey}`) {
1192
+ setClosing(true);
1193
+ const outcome = await saveDraftNow();
1194
+ setClosing(false);
1195
+ if (outcome.skipped) {
1196
+ // It became a message that mustn't be saved as plaintext while waiting on an earlier save.
1197
+ setClosePrompt({ title: DISCARD_TITLE, message: latestRef.current.encryptionDecided ? ENCRYPTED_CLOSE_MESSAGE : CHECKING_CLOSE_MESSAGE });
1198
+ return;
1199
+ }
1200
+ if (!outcome.message) {
1201
+ showSaveFailedPrompt();
1202
+ return;
1203
+ }
1204
+ }
1205
+ finishedRef.current = true;
1206
+ onClose();
1207
+ }
1208
+
1209
+ function retryCryptoContext() {
1210
+ setClosePrompt(null);
1211
+ setCryptoRetryToken((n) => n + 1);
1212
+ lookupFailuresRef.current = new Map();
1213
+ setExhaustedLookups({});
1214
+ setLookupRetryToken((n) => n + 1);
1215
+ }
1216
+
1217
+ // A plain read from keySession.ts's module-level session store, not React state - see that module's
1218
+ // own doc comment. Cheap enough to read fresh on every render rather than caching in state.
1219
+ const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
1220
+ const hasEnrolledSigningKey = !!findActivePublicKey(mailbox?.keys ?? [], "sign");
1221
+ const hasEnrolledEncryptionKey = !!findActivePublicKey(mailbox?.keys ?? [], "encrypt");
1222
+ // This mailbox has a real signing/encryption key on file, but this session hasn't unlocked it yet -
1223
+ // the sign/encrypt toggles below stay hidden until it has, so this is the only way to reach them
1224
+ // without first stumbling into Mail generally (which no longer force-prompts on its own - see
1225
+ // `MailShell`'s `blocking={false}`). `getUnlockedKeys()` is re-read as a plain module value above, so
1226
+ // bumping `unlockRefresh` (after a successful unlock, or on any key-session event for this mailbox -
1227
+ // see the `subscribeKeySession()` effect) is what makes this line (and the toggles it gates) reflect
1228
+ // it on the next render.
1229
+ const needsUnlockForCrypto = (hasEnrolledSigningKey || hasEnrolledEncryptionKey) && !unlockedKeys;
1230
+
1231
+ // Keys locking (idle timeout, logout elsewhere, ...) or unlocking from another window must update the
1232
+ // toggles/unlock affordance right away, not on whatever unrelated render happens next.
1233
+ useEffect(() => subscribeKeySession((event) => {
1234
+ if (event.mailboxUid === mailboxUid) {
1235
+ setUnlockRefresh((n) => n + 1);
1236
+ }
1237
+ }), [mailboxUid]);
1238
+
1239
+ useEffect(() => {
1240
+ offeredCryptoRef.current = {
1241
+ sign: offeredCryptoRef.current.sign || !!unlockedKeys?.signingPrivateKey,
1242
+ encrypt: offeredCryptoRef.current.encrypt || !!unlockedKeys?.encryptionPrivateKey,
1243
+ };
1244
+ });
1245
+
1246
+ // Autosave. "User content" is anything beyond what compose opened with (prefilled recipients/subject,
1247
+ // seeded signature/quote) - an untouched window has nothing worth saving or confirming a discard of.
1248
+ const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
1249
+ const baselineKey = JSON.stringify([baselineTo ?? "", baselineCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
1250
+ const hasUserContent = contentKey !== baselineKey || hasUploads;
1251
+ // A message headed for encryption is never autosaved: the draft would store its plaintext server-side,
1252
+ // defeating end-to-end encryption. Until it's known not to be, it isn't either: while the mailbox (whose
1253
+ // enrolled keys say whether encryption is possible at all) or the encryption policy hasn't loaded, and -
1254
+ // whenever this message could be encrypted (an unlocked or enrolled encryption key) - while the policy is
1255
+ // missing or any current recipient's lookup is still pending (or failed, or hasn't run) - and while there are no
1256
+ // recipients at all when policy auto-encrypts some tier, since the first one added may turn encryption on.
1257
+ const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
1258
+ const currentStatuses = currentAddresses.map((address) => recipientStatuses[address]);
1259
+ const encryptionPossible = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
1260
+ /** Could be auto-encrypted, but has no recipients yet to decide by. */
1261
+ const awaitingRecipients = encryptionPossible && !!encryptionPolicy && currentAddresses.length === 0 && policyCanAutoEncrypt(encryptionPolicy);
1262
+ const encryptionUndetermined =
1263
+ !cryptoContextReady || !mailbox || awaitingRecipients || (encryptionPossible && (!encryptionPolicy || currentStatuses.some((s) => !s)));
1264
+ /** Known to be headed for encryption: requested, or policy auto-encrypts it. */
1265
+ const encryptionDecided = encryptRequested || (!encryptionUndetermined && encryptionPossible && decideMessageEncryption(currentStatuses).autoEncrypt);
1266
+ const autosaveSuppressed = encryptionDecided || encryptionUndetermined;
1267
+ /** Loading the mailbox/policy failed (retries may still be pending) and this message's encryption hinges on it, or
1268
+ * a current recipient's key lookup kept failing. */
1269
+ const cryptoCheckUnavailable =
1270
+ (cryptoContextReady && cryptoLoadFailed && (!mailbox || (encryptionPossible && !encryptionPolicy))) ||
1271
+ (encryptionPossible && currentAddresses.some((address) => exhaustedLookups[address] && !recipientStatuses[address]));
1272
+ const latestRef = useRef({ draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided });
1273
+ latestRef.current = { draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided };
1274
+
1275
+ // Recipients a reply could only work out after this window opened (Reply All: whoever the original's headers name)
1276
+ // replace the ones it opened with - but only in a field the user hasn't touched, never over what they typed.
1277
+ useEffect(() => {
1278
+ if (!late) {
1279
+ return;
1280
+ }
1281
+ const current = latestRef.current;
1282
+ if (late.to !== undefined && current.to === (initialTo ?? "")) {
1283
+ setTo(late.to);
1284
+ }
1285
+ if (late.cc !== undefined && current.cc === (initialCc ?? "")) {
1286
+ setCc(late.cc);
1287
+ setShowCcBcc((shown) => shown || late.cc !== "");
1288
+ }
1289
+ }, [late]);
1290
+
1291
+ useEffect(() => {
1292
+ const due =
1293
+ !!draft &&
1294
+ contentReady &&
1295
+ cryptoContextReady &&
1296
+ !sending &&
1297
+ !autosaveSuppressed &&
1298
+ hasUserContent &&
1299
+ lastSavedRef.current !== `${draft.uid}:${contentKey}`;
1300
+ pendingSaveRef.current = due;
1301
+ if (!due) {
1302
+ return;
1303
+ }
1304
+ const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
1305
+ return () => clearTimeout(timer);
1306
+ }, [draft, contentReady, cryptoContextReady, sending, autosaveSuppressed, hasUserContent, contentKey]);
1307
+
1308
+ // Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
1309
+ // right away rather than losing it.
1310
+ useEffect(
1311
+ () => () => {
1312
+ if (pendingSaveRef.current && !finishedRef.current) {
1313
+ void saveDraftNow();
1314
+ }
1315
+ },
1316
+ [],
1317
+ );
1318
+
1319
+ /** Saves an edit still waiting on the debounce now, and resolves once that save - or one already on the
1320
+ * wire - has settled, to whether the window's content is saved. A save that failed keeps the window open
1321
+ * with Discard / Keep editing. A send clears `pendingSaveRef` before it starts, so this never saves mid-send. */
1322
+ async function flushPendingSave(): Promise<boolean> {
1323
+ if (pendingSaveRef.current && !finishedRef.current) {
1324
+ const outcome = await saveDraftNow();
1325
+ if (outcome.skipped) {
1326
+ // Not saved, but not a failure either: it may be headed for encryption (or was sent/discarded meanwhile).
1327
+ return finishedRef.current;
1328
+ }
1329
+ if (!outcome.message) {
1330
+ showSaveFailedPrompt();
1331
+ }
1332
+ return !!outcome.message;
1333
+ }
1334
+ await saveInFlightRef.current;
1335
+ const latest = latestRef.current;
1336
+ if (finishedRef.current || !latest.hasUserContent || (!!latest.draft && lastSavedRef.current === `${latest.draft.uid}:${latest.contentKey}`)) {
1337
+ return true;
1338
+ }
1339
+ // Unsaved because the last save failed (rather than, say, an encrypted message never being saved).
1340
+ if (saveErrorRef.current) {
1341
+ showSaveFailedPrompt();
1342
+ }
1343
+ return false;
1344
+ }
1345
+
1346
+ // Sign Out (AppShell) waits for this before ending the session.
1347
+ useEffect(() => registerComposeFlush(flushPendingSave), []);
1348
+
1349
+ // Leaving the page (reload, closing the tab, following a link) with anything not saved: start a save that's
1350
+ // waiting on the debounce straight away and ask the browser to confirm, so the last couple of seconds of
1351
+ // typing (or content that can't be saved as a draft, or failed to) aren't lost silently. Never while signing
1352
+ // out - that navigation must not be cancellable.
1353
+ useEffect(() => {
1354
+ function handleBeforeUnload(event: BeforeUnloadEvent) {
1355
+ if (finishedRef.current || sendingRef.current || isSigningOut()) {
1356
+ return;
1357
+ }
1358
+ const latest = latestRef.current;
1359
+ const unsaved =
1360
+ pendingSaveRef.current ||
1361
+ latest.saveStatus === "saving" ||
1362
+ (latest.hasUserContent && lastSavedRef.current !== `${latest.draft?.uid}:${latest.contentKey}`);
1363
+ if (!unsaved) {
1364
+ return;
1365
+ }
1366
+ void flushPendingSave();
1367
+ event.preventDefault();
1368
+ event.returnValue = "";
1369
+ }
1370
+ window.addEventListener("beforeunload", handleBeforeUnload);
1371
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
1372
+ }, []);
1373
+
1374
+ // Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
1375
+ // with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
1376
+ // window must not move the caret there again.
1377
+ const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending;
1378
+ const fieldsFocusedRef = useRef(false);
1379
+ const bodyFocusedRef = useRef(false);
1380
+ const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
1381
+ const focusSubjectOnMount = !isReplyOrForward && !!initialTo && !fieldsFocusedRef.current;
1382
+ const focusBodyOnMount = isReplyOrForward && !bodyFocusedRef.current;
1383
+
1384
+ // The editor serializes the seeded body its own way (see `RichTextEditor`'s `onInitialized`). While the body is
1385
+ // still exactly what compose seeded, that serialization becomes the baseline - so an untouched reply or signature
1386
+ // isn't mistaken for an edit (and autosaved) just because the editor was focused or clicked.
1387
+ /** The quote has been added: unless the reader has typed something already, that is still exactly what compose seeded. */
1388
+ function handleQuoteAppended(normalized: string) {
1389
+ const latest = latestRef.current;
1390
+ if (latest.html === latest.seededHtml) {
1391
+ setHtml(normalized);
1392
+ setSeededHtml(normalized);
1393
+ }
1394
+ }
1395
+ function handleEditorInitialized(normalized: string) {
1396
+ markComposePhase(id, "editor");
1397
+ const latest = latestRef.current;
1398
+ if (latest.html === latest.seededHtml && normalized !== latest.html) {
1399
+ setHtml(normalized);
1400
+ setSeededHtml(normalized);
1401
+ }
1402
+ }
1403
+ useEffect(() => {
1404
+ if (!minimized) {
1405
+ fieldsFocusedRef.current = true;
1406
+ bodyFocusedRef.current ||= contentReady;
1407
+ }
1408
+ }, [minimized, contentReady]);
1409
+
1410
+ // Keyboard shortcuts, active while the focus is inside this window (`container`; the scope attribute on its root is what tells the
1411
+ // dispatcher the focus is in a compose window at all). They do what the Send and Close buttons and autosave do, under the same conditions -
1412
+ // a shortcut pressed while its button would be disabled is consumed and does nothing.
1413
+ const canSend = !!draft && !sending && !closing && cryptoContextReady;
1414
+ useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
1415
+ useShortcut(
1416
+ SHORTCUTS.compose.saveDraft,
1417
+ () => {
1418
+ // Autosave's own conditions: never a draft that must not be stored as plaintext, and nothing while a send is on the wire.
1419
+ if (draft && contentReady && cryptoContextReady && !sending && !closing && !autosaveSuppressed && hasUserContent) {
1420
+ void saveDraftNow();
1421
+ }
1422
+ },
1423
+ { container: windowRef },
1424
+ );
1425
+ useShortcut(
1426
+ SHORTCUTS.compose.close,
1427
+ () => {
1428
+ if (!sending && !closing) {
1429
+ void handleClose();
1430
+ }
1431
+ },
1432
+ { container: windowRef },
1433
+ );
1434
+ useShortcut(SHORTCUTS.compose.create, () => openCompose({ mailboxUid }), { container: windowRef });
1435
+
1436
+ const title = subject.trim() || "New Message";
1437
+ const titleId = `compose-title-${id}`;
1438
+
1439
+ const discardModal = (
1440
+ <Modal open={closePrompt !== null} onClose={() => setClosePrompt(null)} title={closePrompt?.title ?? DISCARD_TITLE}>
1441
+ <p className="text-sm text-text-muted mb-4">{closePrompt?.message}</p>
1442
+ <div className="flex gap-3">
1443
+ {closePrompt?.retry && (
1444
+ <Button type="button" onClick={retryCryptoContext} className="!w-auto">
1445
+ Retry
1446
+ </Button>
1447
+ )}
1448
+ {closePrompt?.closeAnyway && (
1449
+ <Button
1450
+ type="button"
1451
+ onClick={() => {
1452
+ setClosePrompt(null);
1453
+ void handleClose(true);
1454
+ }}
1455
+ disabled={sending || closing}
1456
+ className="!w-auto"
1457
+ >
1458
+ Close, keep draft
1459
+ </Button>
1460
+ )}
1461
+ <Button type="button" onClick={() => void discardNow()} disabled={sending || closing} className="!w-auto">
1462
+ Discard
1463
+ </Button>
1464
+ <Button type="button" variant="secondary" onClick={() => setClosePrompt(null)} className="!w-auto">
1465
+ Keep editing
1466
+ </Button>
1467
+ </div>
1468
+ </Modal>
1469
+ );
1470
+
1471
+ if (minimized) {
1472
+ return (
1473
+ <>
1474
+ <div role="dialog" aria-label={title} className="w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal">
1475
+ <div
1476
+ className="h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer"
1477
+ onClick={onToggleMinimize}
1478
+ >
1479
+ <span className="text-sm font-medium truncate">{title}</span>
1480
+ {sendError && <span className="shrink-0 text-xs font-bold uppercase tracking-wide">Not sent</span>}
1481
+ <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
1482
+ <HeaderButton label="Restore" onClick={onToggleMinimize} icon={HiOutlineArrowsPointingOut} />
1483
+ <HeaderButton label="Discard draft" onClick={handleDiscard} icon={HiOutlineXMark} disabled={sending || closing} />
1484
+ </div>
1485
+ </div>
1486
+ </div>
1487
+ {discardModal}
1488
+ </>
1489
+ );
1490
+ }
1491
+
1492
+ return (
1493
+ <>
1494
+ <div
1495
+ ref={windowRef}
1496
+ role="dialog"
1497
+ data-shortcut-scope="compose"
1498
+ aria-labelledby={titleId}
1499
+ style={!isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
1500
+ className={[
1501
+ "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1502
+ isMobile
1503
+ ? "fixed inset-0 w-full h-full rounded-none border-0"
1504
+ : ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1505
+ ].join(" ")}
1506
+ >
1507
+ {!isMobile && (
1508
+ <>
1509
+ <div
1510
+ onPointerDown={handleResizeStart("top")}
1511
+ aria-hidden="true"
1512
+ className="absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20"
1513
+ />
1514
+ <div
1515
+ onPointerDown={handleResizeStart("left")}
1516
+ aria-hidden="true"
1517
+ className="absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20"
1518
+ />
1519
+ <div
1520
+ onPointerDown={handleResizeStart("corner")}
1521
+ role="separator"
1522
+ aria-label="Resize"
1523
+ className="absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30"
1524
+ />
1525
+ </>
1526
+ )}
1527
+
1528
+ <div
1529
+ className="h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer"
1530
+ onClick={onToggleMinimize}
1531
+ >
1532
+ <span id={titleId} className="text-sm font-medium truncate">
1533
+ {title}
1534
+ </span>
1535
+ <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
1536
+ <HeaderButton label="Minimize" onClick={onToggleMinimize} icon={HiOutlineMinus} />
1537
+ {!isMobile && (
1538
+ <HeaderButton
1539
+ label={expanded ? "Collapse" : "Expand"}
1540
+ onClick={toggleExpanded}
1541
+ icon={expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut}
1542
+ />
1543
+ )}
1544
+ <HeaderButton label="Close" onClick={() => void handleClose()} icon={HiOutlineXMark} disabled={sending || closing} shortcut={SHORTCUTS.compose.close} />
1545
+ </div>
1546
+ </div>
1547
+
1548
+ <div className="flex-1 min-h-0 flex flex-col">
1549
+ {(folderError || draftError || sendError || attachError || discardError) && (
1550
+ // Scrolls inside the window when it is tall (a failed send's expanded Technical details), instead of pushing the
1551
+ // editor and the Send button out of the window's fixed height, where they can't be reached.
1552
+ <div className="px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto">
1553
+ {folderError && <Alert>{folderError}</Alert>}
1554
+ {draftError && <Alert>{draftError}</Alert>}
1555
+ {sendError && <SendFailureAlert message={sendError} lines={sendDetails} />}
1556
+ {attachError && <Alert>{attachError}</Alert>}
1557
+ {discardError && <Alert>{discardError}</Alert>}
1558
+ </div>
1559
+ )}
1560
+
1561
+ {cryptoCheckUnavailable && (
1562
+ <div className="px-3 pt-2">
1563
+ <Alert>
1564
+ <p className="mb-2">{CRYPTO_UNAVAILABLE_MESSAGE}</p>
1565
+ <Button type="button" variant="secondary" className="!w-auto" onClick={retryCryptoContext}>
1566
+ Retry
1567
+ </Button>
1568
+ </Alert>
1569
+ </div>
1570
+ )}
1571
+
1572
+ {encryptionBlocked && (
1573
+ <div className="px-3 pt-2">
1574
+ <Alert>
1575
+ <p className="mb-2">
1576
+ This message can&rsquo;t be encrypted for everyone: {encryptionBlocked.map((r) => r.address).join(", ")}
1577
+ {encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file"}.
1578
+ Remove {encryptionBlocked.length > 1 ? "these recipients" : "this recipient"} from To/Cc/Bcc, or send the
1579
+ whole message in plaintext.
1580
+ </p>
1581
+ <Button type="button" disabled={sending} onClick={() => void submit(true, blockedScheduleIso)}>
1582
+ Send without encryption
1583
+ </Button>
1584
+ </Alert>
1585
+ </div>
1586
+ )}
1587
+
1588
+ {securityBlock && (
1589
+ <div className="px-3 pt-2">
1590
+ <Alert>
1591
+ <p className="mb-2">{securityBlock.message}</p>
1592
+ <Button type="button" disabled={sending} onClick={() => void submit(true, blockedScheduleIso)}>
1593
+ {securityBlock.overrideLabel}
1594
+ </Button>
1595
+ </Alert>
1596
+ </div>
1597
+ )}
1598
+
1599
+ {fromOptions.length > 1 && (
1600
+ <div className={FIELD_ROW}>
1601
+ <label htmlFor={`compose-from-${id}`} className="text-xs text-text-muted shrink-0">
1602
+ From
1603
+ </label>
1604
+ <select
1605
+ id={`compose-from-${id}`}
1606
+ className={`${FIELD_INPUT} disabled:opacity-55`}
1607
+ value={mailboxUid ?? ""}
1608
+ disabled={hasUploads || sending}
1609
+ title={hasUploads ? "The sender can't be changed after adding attachments or images." : undefined}
1610
+ onChange={(e) => handleFromChange(e.target.value)}
1611
+ >
1612
+ {fromOptions.map((mb) => (
1613
+ <option key={mb.uid} value={mb.uid}>
1614
+ {mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress}
1615
+ {mb.ownerUserUid ? "" : " (shared)"}
1616
+ </option>
1617
+ ))}
1618
+ </select>
1619
+ </div>
1620
+ )}
1621
+
1622
+ <div className={FIELD_ROW}>
1623
+ <label htmlFor={`compose-to-${id}`} className="text-xs text-text-muted shrink-0">
1624
+ To
1625
+ </label>
1626
+ <RecipientInput
1627
+ id={`compose-to-${id}`}
1628
+ label="To"
1629
+ value={to}
1630
+ mailboxUid={mailboxUid}
1631
+ onChange={setTo}
1632
+ onBlur={(value) => checkRecipientDiscovery(parseAddresses(value))}
1633
+ onCommit={(value) => checkRecipientDiscovery(parseAddresses(value))}
1634
+ autoFocus={focusToOnMount}
1635
+ />
1636
+ {!showCcBcc && (
1637
+ <button
1638
+ type="button"
1639
+ onClick={() => setShowCcBcc(true)}
1640
+ className="text-xs text-text-muted hover:text-text shrink-0"
1641
+ >
1642
+ Cc Bcc
1643
+ </button>
1644
+ )}
1645
+ </div>
1646
+
1647
+ {showCcBcc && (
1648
+ <>
1649
+ <div className={FIELD_ROW}>
1650
+ <label htmlFor={`compose-cc-${id}`} className="text-xs text-text-muted shrink-0">
1651
+ Cc
1652
+ </label>
1653
+ <RecipientInput
1654
+ id={`compose-cc-${id}`}
1655
+ label="Cc"
1656
+ value={cc}
1657
+ mailboxUid={mailboxUid}
1658
+ onChange={setCc}
1659
+ onBlur={(value) => checkRecipientDiscovery(parseAddresses(value))}
1660
+ onCommit={(value) => checkRecipientDiscovery(parseAddresses(value))}
1661
+ />
1662
+ </div>
1663
+ <div className={FIELD_ROW}>
1664
+ <label htmlFor={`compose-bcc-${id}`} className="text-xs text-text-muted shrink-0">
1665
+ Bcc
1666
+ </label>
1667
+ <RecipientInput
1668
+ id={`compose-bcc-${id}`}
1669
+ label="Bcc"
1670
+ value={bcc}
1671
+ mailboxUid={mailboxUid}
1672
+ onChange={setBcc}
1673
+ onBlur={(value) => checkRecipientDiscovery(parseAddresses(value))}
1674
+ onCommit={(value) => checkRecipientDiscovery(parseAddresses(value))}
1675
+ />
1676
+ </div>
1677
+ </>
1678
+ )}
1679
+
1680
+ {(() => {
1681
+ const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
1682
+ .map((r) => recipientStatuses[r.address])
1683
+ .filter((status): status is RecipientEncryptionStatus => !!status);
1684
+ if (knownRecipients.length === 0) {
1685
+ return null;
1686
+ }
1687
+ return (
1688
+ <ul className="flex flex-wrap gap-1.5 px-3 pt-1.5" aria-label="Recipient encryption availability">
1689
+ {knownRecipients.map((status) => (
1690
+ <li
1691
+ key={status.address}
1692
+ className={`text-xs font-medium py-0.5 px-2 rounded-pill ${
1693
+ status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"
1694
+ }`}
1695
+ >
1696
+ {status.address} {status.canEncrypt ? "supports encryption" : "no encryption key found"}
1697
+ </li>
1698
+ ))}
1699
+ </ul>
1700
+ );
1701
+ })()}
1702
+
1703
+ <div className={FIELD_ROW}>
1704
+ <input
1705
+ aria-label="Subject"
1706
+ type="text"
1707
+ placeholder="Subject"
1708
+ className={FIELD_INPUT}
1709
+ value={subject}
1710
+ autoFocus={focusSubjectOnMount}
1711
+ onChange={(e) => setSubject(e.target.value)}
1712
+ />
1713
+ </div>
1714
+
1715
+ <div className="flex-1 min-h-0 p-2">
1716
+ {contentReady && (
1717
+ <RichTextEditor
1718
+ appendHtml={lateQuoteHtml}
1719
+ onAppended={handleQuoteAppended}
1720
+ value={html}
1721
+ onChange={setHtml}
1722
+ fill
1723
+ onUploadImage={handleUploadImage}
1724
+ autoFocusStart={focusBodyOnMount}
1725
+ onInitialized={handleEditorInitialized}
1726
+ />
1727
+ )}
1728
+ </div>
1729
+
1730
+ {quotePending && (
1731
+ <p role="status" className="px-3 pb-1 text-xs text-text-muted">
1732
+ Loading the original message&hellip;
1733
+ </p>
1734
+ )}
1735
+
1736
+ {attachments.length > 0 && (
1737
+ <ul className="flex flex-wrap gap-2 px-3 pb-2">
1738
+ {attachments.map((attachment) => (
1739
+ <li key={attachment.uid} className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted">
1740
+ {attachment.filename}
1741
+ </li>
1742
+ ))}
1743
+ </ul>
1744
+ )}
1745
+
1746
+ <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
1747
+ <input type="checkbox" checked={requestReceipt} onChange={(e) => setRequestReceipt(e.target.checked)} />
1748
+ Request a read receipt
1749
+ </label>
1750
+
1751
+ {needsUnlockForCrypto && (
1752
+ <div className="px-3 pb-1">
1753
+ <button
1754
+ type="button"
1755
+ onClick={handleUnlockForCrypto}
1756
+ className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
1757
+ >
1758
+ <HiOutlineLockClosed size={12} aria-hidden="true" />
1759
+ Unlock to sign or encrypt this message
1760
+ </button>
1761
+ </div>
1762
+ )}
1763
+
1764
+ {/* Only rendered once this session actually has a usable signing/encryption key - see
1765
+ `assembleForSend()`'s own doc comment for why signing rarely shows today (no signing
1766
+ certificate exists until RFC 8823 ACME enrollment lands). */}
1767
+ {unlockedKeys?.signingPrivateKey && (
1768
+ <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
1769
+ <input type="checkbox" checked={signEnabled} onChange={(e) => setSignEnabled(e.target.checked)} />
1770
+ Digitally sign this message
1771
+ </label>
1772
+ )}
1773
+ {unlockedKeys?.encryptionPrivateKey && (
1774
+ <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
1775
+ <input type="checkbox" checked={encryptRequested} onChange={(e) => setEncryptRequested(e.target.checked)} />
1776
+ Encrypt this message
1777
+ </label>
1778
+ )}
1779
+
1780
+ <div className="shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border">
1781
+ <div className="flex items-center rounded-pill bg-primary text-white overflow-hidden">
1782
+ <button
1783
+ type="button"
1784
+ onClick={() => void submit(false)}
1785
+ title={withHint("Send", SHORTCUTS.compose.send, keyEnv)}
1786
+ aria-keyshortcuts={ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv)}
1787
+ disabled={!draft || sending || closing || !cryptoContextReady}
1788
+ className="py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
1789
+ >
1790
+ {sending ? "Sending…" : "Send"}
1791
+ </button>
1792
+ <button
1793
+ ref={scheduleButtonRef}
1794
+ type="button"
1795
+ aria-label="Send later"
1796
+ aria-haspopup="true"
1797
+ aria-expanded={schedulePickerOpen}
1798
+ disabled={!draft || sending || closing || !cryptoContextReady}
1799
+ onClick={() => setSchedulePickerOpen((o) => !o)}
1800
+ className="py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
1801
+ >
1802
+ <HiOutlineChevronDown size={14} />
1803
+ </button>
1804
+ </div>
1805
+ {schedulePickerOpen && (
1806
+ <ScheduleSendPicker
1807
+ anchorRef={scheduleButtonRef}
1808
+ onClose={() => setSchedulePickerOpen(false)}
1809
+ onSchedule={handleScheduleSend}
1810
+ />
1811
+ )}
1812
+
1813
+ <label
1814
+ aria-label="Attach files"
1815
+ title="Attach files"
1816
+ 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"
1817
+ >
1818
+ <HiOutlinePaperClip size={18} />
1819
+ <input type="file" multiple disabled={!draft} onChange={handleFilesSelected} className="sr-only" />
1820
+ </label>
1821
+
1822
+ <span role="status" aria-live="polite" className="ml-auto text-xs text-text-muted">
1823
+ {SAVE_STATUS_LABEL[saveStatus]}
1824
+ </span>
1825
+
1826
+ <button
1827
+ type="button"
1828
+ aria-label="Discard draft"
1829
+ title="Discard draft"
1830
+ onClick={handleDiscard}
1831
+ disabled={sending || closing}
1832
+ className="w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed"
1833
+ >
1834
+ <HiOutlineTrash size={18} />
1835
+ </button>
1836
+ </div>
1837
+ </div>
1838
+ </div>
1839
+ {discardModal}
1840
+ </>
1841
+ );
1842
+ }