@rapidmx/web-client 0.3.1 → 0.5.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 (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -4,20 +4,58 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useRef, useState } from "react";
7
- import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevronDown, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
7
+ import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevronDown, HiOutlineLockClosed, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
8
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
- import { assembleDraft, assembleDraftRaw, attachmentContentUrl, createDraft, getMailbox, listFolders, sendMessage, setMessageRequestReceipt, setMessageScheduledSendTime, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import { assembleDraft, assembleDraftRaw, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, sendMessage, setMessageRequestReceipt, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
10
10
  import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
11
+ import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
11
12
  import { decideMessageEncryption, resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
12
- import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
13
- import { getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
13
+ import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
14
+ import { findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
15
+ import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
14
16
  import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
15
17
  import { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
16
18
  import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
19
+ import { isSigningOut, registerComposeFlush } from "./composeFlushRegistry.js";
17
20
  import RichTextEditor from "./RichTextEditor.js";
18
21
  import ScheduleSendPicker from "./ScheduleSendPicker.js";
19
22
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
20
23
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
24
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
25
+ /** Debounce between the last edit and the draft autosave. */
26
+ export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
27
+ /** Automatic retries of a failed mailbox/encryption-policy load; after the last one only "Retry" tries again. */
28
+ export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000];
29
+ /** Signed/encrypted bodies are built client-side from the editor's HTML verbatim, so an inline image
30
+ * (an uploaded attachment previewed via its content URL, or a `cid:` reference) would never be carried -
31
+ * only `assembleDraft()`'s server-side plaintext path rewrites those into real MIME parts. */
32
+ const INLINE_IMAGE_PATTERN = /<img\b[^>]*\bsrc\s*=\s*["']?(?:cid:|[^"'\s>]*\/mail\/attachments\/[^"'\s>]+\/content)/i;
33
+ const KEYS_LOCKED_SIGN_MESSAGE = "This message can't be signed right now - your signing key is locked or your mailbox details couldn't be loaded. Unlock and send again, or send it without signing.";
34
+ const KEYS_LOCKED_ENCRYPT_MESSAGE = "This message can't be encrypted right now - your encryption key is locked. Unlock and send again, or send it without encryption.";
35
+ const POLICY_UNAVAILABLE_MESSAGE = "Your encryption settings couldn't be loaded, so this message can't be encrypted right now. Try again, or send it without encryption.";
36
+ const BCC_ENCRYPTED_MESSAGE = "Bcc recipients can't be used with encrypted messages. Remove Bcc recipients or turn off encryption.";
37
+ const LOOKUP_UNAVAILABLE_MESSAGE = "Your recipients' encryption keys couldn't be checked, so this message might go out unencrypted when it should be encrypted. Try again, or send it without encryption.";
38
+ /** Thrown by `deleteDraft()` when the server copy is no longer this window's draft (sent or scheduled elsewhere). */
39
+ const NO_LONGER_A_DRAFT_MESSAGE = "This message was already sent or scheduled from another window, so it wasn't deleted.";
40
+ /** The server refuses signed/encrypted mail whose From display name differs from its own safe form, which drops a
41
+ * name containing `@` (or a look-alike) or a line break. */
42
+ const UNSAFE_DISPLAY_NAME_PATTERN = /[@@﹫\r\n]/;
43
+ /** Whether `fresh` (a re-read of a draft) is still an unsent draft in `folderUid`: never delete a message another
44
+ * window has since sent, scheduled, or started sending. */
45
+ function isStillDraft(fresh, folderUid) {
46
+ return fresh.folderUid === folderUid && !fresh.scheduledSendTime && !fresh.scheduledSendLeaseExpiresAt && !fresh.scheduledSendRelayedAt;
47
+ }
48
+ /** Any tier of `policy` auto-encrypts - only then can a message with recipients still to be added turn out encrypted
49
+ * without the user asking. */
50
+ function policyCanAutoEncrypt(policy) {
51
+ return [policy.encryptSameOrg, policy.encryptFederated, policy.encryptExternal].includes("automatic");
52
+ }
53
+ const SAVE_STATUS_LABEL = { idle: "", saving: "Saving…", saved: "Draft saved", error: "Couldn't save draft" };
54
+ const DISCARD_TITLE = "Discard this draft?";
55
+ const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so closing this window discards what you've written.";
56
+ const CHECKING_CLOSE_MESSAGE = "This message may be encrypted, and that's still being checked, so it can't be saved as a draft yet. Keep editing and close again in a moment, or discard it.";
57
+ const CRYPTO_UNAVAILABLE_MESSAGE = "Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
58
+ const NO_RECIPIENTS_CLOSE_MESSAGE = "This message may be encrypted once its recipients are known, so it isn't saved as a draft until you add them. Add a recipient and close again, or discard it.";
21
59
  const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
22
60
  const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
23
61
  const MIN_WIDTH = 320;
@@ -36,8 +74,8 @@ function parseAddresses(value) {
36
74
  .filter((s) => s.length > 0)
37
75
  .map((address) => ({ address }));
38
76
  }
39
- function HeaderButton({ label, onClick, icon: Icon }) {
40
- return (_jsx("button", { type: "button", "aria-label": label, title: label, onClick: onClick, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white", children: _jsx(Icon, { size: 14 }) }));
77
+ function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
78
+ return (_jsx("button", { type: "button", "aria-label": label, title: label, onClick: onClick, disabled: disabled, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(Icon, { size: 14 }) }));
41
79
  }
42
80
  /**
43
81
  * A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
@@ -53,13 +91,28 @@ function HeaderButton({ label, onClick, icon: Icon }) {
53
91
  * see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
54
92
  * once.
55
93
  */
56
- export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
57
- const { id, mailboxUid, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, suppressSigning, minimized } = session;
94
+ export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS, }) {
95
+ const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, suppressSigning, minimized } = session;
96
+ // The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
97
+ // compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
98
+ // Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
99
+ const [fromMailboxUid, setFromMailboxUid] = useState(session.mailboxUid);
100
+ const mailboxUid = fromMailboxUid;
101
+ const [mailboxes, setMailboxes] = useState([]);
102
+ // Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
103
+ // Drafts folder - so once anything has been uploaded the sender can no longer be switched.
104
+ const [hasUploads, setHasUploads] = useState(false);
58
105
  const isMobile = useIsMobile();
106
+ const { requestUnlock } = useUnlockPrompt();
107
+ // Bumped after a successful on-demand unlock to force a re-render - `getUnlockedKeys()` below is a
108
+ // plain read from a module-level store, not React state, so nothing else would pick up the change.
109
+ const [, setUnlockRefresh] = useState(0);
59
110
  const windowRef = useRef(null);
60
111
  const [draftsFolderUid, setDraftsFolderUid] = useState();
61
112
  const [folderError, setFolderError] = useState(null);
62
113
  const [draft, setDraft] = useState(null);
114
+ // Drafts replaced by a From switch, awaiting deletion until their replacement has actually been created.
115
+ const supersededDraftsRef = useRef([]);
63
116
  const [draftError, setDraftError] = useState(null);
64
117
  const [expanded, setExpanded] = useState(false);
65
118
  const [manualSize, setManualSize] = useState(null);
@@ -91,77 +144,211 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
91
144
  // just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
92
145
  // never cleared, so switching focus between To/Cc/Bcc repeatedly doesn't re-trigger lookups.
93
146
  const [recipientStatuses, setRecipientStatuses] = useState({});
94
- // Best-effort, same as the signature-list fetch below: a mailbox with no keys enrolled yet (or a
95
- // failed fetch) just means sign/encrypt stay unavailable for this compose session, never a blocking
96
- // error - encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
147
+ // Addresses whose lookup is on the wire (so the blur handlers and the prefilled-recipient effect don't
148
+ // look one up twice), and a counter bumped by a From switch so a lookup for the previous sender is dropped.
149
+ const lookupsInFlightRef = useRef(new Set());
150
+ const discoveryGenerationRef = useRef(0);
151
+ // A failed lookup stores no status (it would read as "no key" and let a message that must be encrypted autosave
152
+ // or send as plaintext): it's retried with the same backoff as the mailbox/policy load, and once those retries
153
+ // run out the address is "exhausted" - shown as a check that couldn't be done, with Retry.
154
+ const lookupFailuresRef = useRef(new Map());
155
+ const lookupRetryTimersRef = useRef(new Set());
156
+ const [lookupRetryToken, setLookupRetryToken] = useState(0);
157
+ const [exhaustedLookups, setExhaustedLookups] = useState({});
158
+ // A send refused because signing/encryption was wanted but can't happen right now (keys locked, the
159
+ // encryption policy/mailbox couldn't be loaded, or Bcc recipients on an encrypted message) - never a
160
+ // silent downgrade to plaintext; the user has to explicitly pick the override.
161
+ const [securityBlock, setSecurityBlock] = useState(null);
162
+ // The schedule time of the send that got blocked (undefined for an immediate send), so the banners'
163
+ // override buttons replay the same kind of send the user originally asked for.
164
+ const [blockedScheduleIso, setBlockedScheduleIso] = useState();
165
+ // Which crypto toggles were ever shown during this compose session (per sending mailbox). Keys that
166
+ // lock after that point must block the send rather than quietly dropping the signature/encryption.
167
+ const offeredCryptoRef = useRef({ sign: false, encrypt: false });
168
+ // Draft autosave / discard bookkeeping.
169
+ const [seededHtml, setSeededHtml] = useState();
170
+ const [saveStatus, setSaveStatus] = useState("idle");
171
+ const [closePrompt, setClosePrompt] = useState(null);
172
+ /** A Close (saving) or Discard (deleting) is on the wire - the window closes once it succeeds. */
173
+ const [closing, setClosing] = useState(false);
174
+ const [discardError, setDiscardError] = useState(null);
175
+ /** Why the last save failed, cleared by the next successful one. */
176
+ const saveErrorRef = useRef(null);
177
+ /** `<draftUid>:<content key>` of the last successful save. */
178
+ const lastSavedRef = useRef(null);
179
+ /** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
180
+ const pendingSaveRef = useRef(false);
181
+ /** Mirrors `sending` for the page-leave/sign-out flushes, which run outside React's render cycle. */
182
+ const sendingRef = useRef(false);
183
+ /** The latest save request, if any - each save is chained after the one before it. Always settles (never
184
+ * rejects), to the saved message, or `undefined` when that save failed. */
185
+ const saveInFlightRef = useRef(Promise.resolve(undefined));
186
+ /** Set once the window has been sent/closed/discarded - nothing more gets autosaved after that. */
187
+ const finishedRef = useRef(false);
188
+ // A mailbox with no keys enrolled yet just means sign/encrypt stay unavailable for this compose session -
189
+ // encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
97
190
  // `cryptoContextReady` (below) gates Send/Send-later until both calls have settled either way, so a
98
191
  // send that happens to race this fetch can't silently skip encryption the spec says should apply.
192
+ // A failed load is retried with backoff (`cryptoRetryDelaysMs`), then on demand ("Retry"): until both have
193
+ // loaded, whether this message must be encrypted is unknown, so it isn't autosaved (see `autosaveSuppressed`).
99
194
  const [cryptoContextReady, setCryptoContextReady] = useState(false);
195
+ const [cryptoLoadFailed, setCryptoLoadFailed] = useState(false);
196
+ const [cryptoRetryToken, setCryptoRetryToken] = useState(0);
197
+ useEffect(() => {
198
+ let cancelled = false;
199
+ listMailboxes({ limit: 100 })
200
+ .then((result) => {
201
+ if (cancelled) {
202
+ return;
203
+ }
204
+ setMailboxes(result);
205
+ // Never default to a mailbox already known to be view-only (see the effect below for one
206
+ // that turns out to be).
207
+ const candidates = result.filter((mb) => peekMailboxWritability(mb, userUid, trusted) !== false);
208
+ setFromMailboxUid((current) => current ?? (candidates.find((mb) => mb.ownerUserUid === userUid) ?? candidates[0])?.uid);
209
+ })
210
+ .catch((err) => {
211
+ // Only fatal when there's no mailbox to fall back on - a session that already names one just
212
+ // loses the From picker.
213
+ if (!cancelled && !session.mailboxUid) {
214
+ setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
215
+ }
216
+ });
217
+ return () => {
218
+ cancelled = true;
219
+ };
220
+ }, []);
100
221
  useEffect(() => {
222
+ if (!mailboxUid) {
223
+ return;
224
+ }
101
225
  let cancelled = false;
226
+ let timer;
227
+ // A retry only fetches what's still missing.
228
+ let mailboxLoaded = mailbox?.uid === mailboxUid;
229
+ let policyLoaded = !!encryptionPolicy;
230
+ let retries = 0;
102
231
  setCryptoContextReady(false);
103
- let mailboxSettled = false;
104
- let policySettled = false;
105
- function checkReady() {
106
- if (!cancelled && mailboxSettled && policySettled) {
107
- setCryptoContextReady(true);
232
+ setCryptoLoadFailed(false);
233
+ async function load() {
234
+ await Promise.all([
235
+ mailboxLoaded ||
236
+ getMailbox(mailboxUid).then((result) => {
237
+ mailboxLoaded = true;
238
+ if (!cancelled) {
239
+ setMailbox(result);
240
+ }
241
+ }, () => undefined),
242
+ policyLoaded ||
243
+ getEncryptionPolicy().then((result) => {
244
+ policyLoaded = true;
245
+ if (!cancelled) {
246
+ setEncryptionPolicy(result);
247
+ }
248
+ }, () => undefined),
249
+ ]);
250
+ if (cancelled) {
251
+ return;
252
+ }
253
+ const failed = !mailboxLoaded || !policyLoaded;
254
+ setCryptoContextReady(true);
255
+ setCryptoLoadFailed(failed);
256
+ if (failed && retries < cryptoRetryDelaysMs.length) {
257
+ timer = setTimeout(() => void load(), cryptoRetryDelaysMs[retries++]);
108
258
  }
109
259
  }
110
- getMailbox(mailboxUid)
111
- .then((result) => {
260
+ void load();
261
+ return () => {
262
+ cancelled = true;
263
+ clearTimeout(timer);
264
+ };
265
+ }, [mailboxUid, cryptoRetryToken]);
266
+ useEffect(() => {
267
+ if (!mailboxUid) {
268
+ return;
269
+ }
270
+ // Cancellable so a slow response for a mailbox the user has since switched away from can't point
271
+ // the new draft at the wrong mailbox's Drafts folder.
272
+ let cancelled = false;
273
+ listFolders(mailboxUid)
274
+ .then((folders) => {
112
275
  if (!cancelled) {
113
- setMailbox(result);
276
+ setDraftsFolderUid(folders.find((f) => f.type === "drafts")?.uid);
114
277
  }
115
278
  })
116
- .catch(() => undefined)
117
- .finally(() => {
118
- mailboxSettled = true;
119
- checkReady();
120
- });
121
- getEncryptionPolicy()
122
- .then((result) => {
279
+ .catch((err) => {
123
280
  if (!cancelled) {
124
- setEncryptionPolicy(result);
281
+ setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder.");
125
282
  }
126
- })
127
- .catch(() => undefined)
128
- .finally(() => {
129
- policySettled = true;
130
- checkReady();
131
283
  });
132
284
  return () => {
133
285
  cancelled = true;
134
286
  };
135
287
  }, [mailboxUid]);
136
- useEffect(() => {
137
- listFolders(mailboxUid)
138
- .then((folders) => setDraftsFolderUid(folders.find((f) => f.type === "drafts")?.uid))
139
- .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder."));
140
- }, [mailboxUid]);
141
288
  /**
142
289
  * Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
143
290
  * caching the result so the small per-recipient indicator below the recipient fields can update
144
291
  * without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
145
292
  * and not the same lookup `assembleForSend()` performs again at send time (deliberately: this is a
146
293
  * best-effort UI hint, not something a stale value here should be trusted to skip at send time).
147
- * A failed lookup or a mailbox/policy fetch that hasn't resolved yet just leaves that address with no
148
- * indicator rather than surfacing an error over what's a cosmetic nicety.
294
+ * A mailbox/policy fetch that hasn't resolved yet just leaves that address with no indicator - the effect
295
+ * below runs it again for every current recipient once both have loaded. A failed lookup leaves it without
296
+ * a status too, and is retried after `cryptoRetryDelaysMs` (then only on blur, Close or Retry).
297
+ *
298
+ * The statuses also drive autosave: a recipient without one yet (lookup pending, failed or never run) or a
299
+ * decision that would auto-encrypt keeps the plaintext draft save off (see `autosaveSuppressed`).
149
300
  */
150
301
  function checkRecipientDiscovery(addresses) {
151
302
  if (!mailbox || !encryptionPolicy) {
152
303
  return;
153
304
  }
154
305
  const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
155
- const unchecked = addresses.filter((r) => !(r.address in recipientStatuses));
306
+ const generation = discoveryGenerationRef.current;
307
+ const inFlight = lookupsInFlightRef.current;
308
+ const unchecked = addresses.filter((r) => !(r.address in recipientStatuses) && !inFlight.has(r.address));
156
309
  for (const recipient of unchecked) {
157
- lookupKeys(mailboxUid, recipient.address)
158
- .catch(() => undefined)
159
- .then((lookup) => {
160
- const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, lookup);
310
+ inFlight.add(recipient.address);
311
+ void lookupKeys(mailbox.uid, recipient.address)
312
+ .then((lookup) => ({ lookup }), () => null)
313
+ .then((result) => {
314
+ inFlight.delete(recipient.address);
315
+ if (generation !== discoveryGenerationRef.current) {
316
+ return;
317
+ }
318
+ if (!result) {
319
+ noteLookupFailed(recipient.address);
320
+ return;
321
+ }
322
+ lookupFailuresRef.current.delete(recipient.address);
323
+ const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
161
324
  setRecipientStatuses((prev) => ({ ...prev, [recipient.address]: status }));
162
325
  });
163
326
  }
164
327
  }
328
+ /** Schedules the next automatic retry of a failed lookup, or marks the address exhausted once they've run out. */
329
+ function noteLookupFailed(address) {
330
+ const failures = (lookupFailuresRef.current.get(address) ?? 0) + 1;
331
+ lookupFailuresRef.current.set(address, failures);
332
+ if (failures > cryptoRetryDelaysMs.length) {
333
+ setExhaustedLookups((prev) => ({ ...prev, [address]: true }));
334
+ return;
335
+ }
336
+ const timer = setTimeout(() => {
337
+ lookupRetryTimersRef.current.delete(timer);
338
+ setLookupRetryToken((n) => n + 1);
339
+ }, cryptoRetryDelaysMs[failures - 1]);
340
+ lookupRetryTimersRef.current.add(timer);
341
+ }
342
+ useEffect(() => () => {
343
+ for (const timer of lookupRetryTimersRef.current) {
344
+ clearTimeout(timer);
345
+ }
346
+ }, []);
347
+ // Prefilled recipients (a reply's To/Cc) are never blurred, and a blur before the mailbox/policy loaded
348
+ // was ignored - look every current recipient up as soon as both are available (and again on a lookup retry).
349
+ useEffect(() => {
350
+ checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
351
+ }, [mailbox, encryptionPolicy, lookupRetryToken]);
165
352
  // Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
166
353
  // plus any quoted original message, before `RichTextEditor` ever mounts (gated by `contentReady`
167
354
  // below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
@@ -171,23 +358,131 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
171
358
  // signature is a completely legitimate outcome, so this falls back to just the quoted content
172
359
  // (if any) rather than surfacing an error over what's a cosmetic nicety.
173
360
  useEffect(() => {
361
+ // Seeds the body once, from whichever mailbox is the sender when compose opens - switching From
362
+ // later must not overwrite what the user has already written (the editor itself never re-syncs).
363
+ if (!mailboxUid || contentReady) {
364
+ return;
365
+ }
174
366
  listMailSignatures(mailboxUid)
175
367
  .then((signatures) => {
176
368
  const signature = signatures.find((s) => signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward);
177
369
  const signatureHtml = signature?.contentHtml ? `${signature.contentHtml}<p></p>` : "";
178
- setHtml(`${signatureHtml}${initialQuotedHtml ?? ""}`);
370
+ const seeded = `${signatureHtml}${initialQuotedHtml ?? ""}`;
371
+ setHtml(seeded);
372
+ setSeededHtml(seeded);
373
+ })
374
+ .catch(() => {
375
+ setHtml(initialQuotedHtml ?? "");
376
+ setSeededHtml(initialQuotedHtml ?? "");
179
377
  })
180
- .catch(() => setHtml(initialQuotedHtml ?? ""))
181
378
  .finally(() => setContentReady(true));
182
379
  }, [mailboxUid, signatureContext, initialQuotedHtml]);
183
380
  useEffect(() => {
184
- if (!draftsFolderUid || draft) {
381
+ if (!mailboxUid || !draftsFolderUid || draft) {
185
382
  return;
186
383
  }
384
+ let cancelled = false;
187
385
  createDraft(mailboxUid, draftsFolderUid)
188
- .then(setDraft)
189
- .catch((err) => setDraftError(err instanceof ApiRequestError ? err.message : "Could not start a new draft."));
386
+ .then((created) => {
387
+ if (cancelled) {
388
+ // The sender changed while this was in flight - discard the now-orphaned draft.
389
+ void deleteMessage(created.uid, created.version).catch(() => undefined);
390
+ return;
391
+ }
392
+ setDraft(created);
393
+ // Only now that the replacement exists is it safe to discard the draft(s) a From switch
394
+ // superseded - if creating this one had failed, the earlier draft is still there.
395
+ for (const superseded of supersededDraftsRef.current.splice(0)) {
396
+ void deleteSupersededDraft(superseded);
397
+ }
398
+ })
399
+ .catch((err) => {
400
+ if (!cancelled) {
401
+ setDraftError(err instanceof ApiRequestError ? err.message : "Could not start a new draft.");
402
+ }
403
+ });
404
+ return () => {
405
+ cancelled = true;
406
+ };
190
407
  }, [mailboxUid, draftsFolderUid, draft]);
408
+ // Drafts a From switch superseded are otherwise only deleted once their replacement exists - closing the
409
+ // window (or sending, which closes it) first would orphan them.
410
+ useEffect(() => () => {
411
+ for (const superseded of supersededDraftsRef.current.splice(0)) {
412
+ void deleteSupersededDraft(superseded);
413
+ }
414
+ }, []);
415
+ /**
416
+ * Deletes a draft a From switch replaced. An autosave of it may still be on the wire - it bumps the
417
+ * draft's version (see `saveDraftNow()`, which keeps `superseded.version` current while it's queued), so
418
+ * the delete waits for it and uses its version; a delete that still fails (e.g. another save of it landed
419
+ * meanwhile) is retried once with the version the server currently holds - unless it's no longer a draft
420
+ * (another window sent or scheduled it), which is left alone.
421
+ * Best-effort, never rejects.
422
+ */
423
+ async function deleteSupersededDraft(superseded) {
424
+ const saved = await saveInFlightRef.current;
425
+ // Once it's out of `supersededDraftsRef` a landing save can't update `superseded.version` any more.
426
+ const version = saved?.uid === superseded.uid ? saved.version : superseded.version;
427
+ try {
428
+ await deleteMessage(superseded.uid, version);
429
+ }
430
+ catch {
431
+ try {
432
+ const fresh = await getMessage(superseded.uid);
433
+ if (isStillDraft(fresh, superseded.folderUid)) {
434
+ await deleteMessage(fresh.uid, fresh.version);
435
+ }
436
+ }
437
+ catch {
438
+ // Already gone, or the server is unreachable - nothing more to do from here.
439
+ }
440
+ }
441
+ }
442
+ // The From picker lists every mailbox straight away and drops view-only ones as each check answers - only
443
+ // mailboxes the caller can create a draft in are worth offering.
444
+ const writability = useMailboxWritability(mailboxes, userUid, trusted);
445
+ const fromOptions = mailboxes.filter((mb) => mb.uid === mailboxUid || writability[mb.uid] !== false);
446
+ // The sender turned out to be view-only (e.g. a reply to a message in a mailbox shared read-only, or a
447
+ // first-listed shared mailbox): switch to one the caller can actually send from, preferring their own.
448
+ const fromIsViewOnly = !!mailboxUid && writability[mailboxUid] === false;
449
+ useEffect(() => {
450
+ if (!fromIsViewOnly || hasUploads) {
451
+ return;
452
+ }
453
+ const ordered = [...mailboxes.filter((mb) => mb.ownerUserUid === userUid), ...mailboxes.filter((mb) => mb.ownerUserUid !== userUid)];
454
+ const alternative = ordered.find((mb) => writability[mb.uid] === true) ?? ordered.find((mb) => writability[mb.uid] === undefined);
455
+ if (alternative) {
456
+ handleFromChange(alternative.uid);
457
+ }
458
+ }, [fromIsViewOnly, writability]);
459
+ /** Switches the sending mailbox: supersedes the current (unsent, upload-free) draft and resets the
460
+ * per-mailbox state, so the effects above start a fresh draft and crypto context in the new mailbox -
461
+ * the old draft is deleted only once the new one has been created. Recipients, subject, and body are
462
+ * kept. */
463
+ function handleFromChange(nextMailboxUid) {
464
+ if (nextMailboxUid === mailboxUid || hasUploads) {
465
+ return;
466
+ }
467
+ if (draft) {
468
+ supersededDraftsRef.current.push(draft);
469
+ }
470
+ setDraft(null);
471
+ setDraftsFolderUid(undefined);
472
+ setDraftError(null);
473
+ setFolderError(null);
474
+ setMailbox(null);
475
+ setRecipientStatuses({});
476
+ discoveryGenerationRef.current += 1;
477
+ lookupsInFlightRef.current = new Set();
478
+ lookupFailuresRef.current = new Map();
479
+ setExhaustedLookups({});
480
+ setEncryptRequested(false);
481
+ setEncryptionBlocked(null);
482
+ setSecurityBlock(null);
483
+ offeredCryptoRef.current = { sign: false, encrypt: false };
484
+ setFromMailboxUid(nextMailboxUid);
485
+ }
191
486
  /**
192
487
  * Click-and-drag resize, from the window's own top/left edges (and the top-left corner, for both
193
488
  * at once) — the window is anchored bottom-right (see `ComposeContext.tsx`'s stack), so growing it
@@ -241,6 +536,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
241
536
  }
242
537
  try {
243
538
  const attachment = await uploadAttachment(draft.uid, file);
539
+ setHasUploads(true);
540
+ void refreshDraftVersion(draft.uid);
244
541
  return attachmentContentUrl(attachment.uid);
245
542
  }
246
543
  catch (err) {
@@ -248,6 +545,18 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
248
545
  return null;
249
546
  }
250
547
  }
548
+ /** Uploading an attachment bumps the draft's version server-side (it records `hasAttachments`), so a later
549
+ * Discard would otherwise send a stale `?version=`. Best-effort: Discard also retries with the server's
550
+ * current version. */
551
+ async function refreshDraftVersion(uid) {
552
+ try {
553
+ const fresh = await getMessage(uid);
554
+ setDraft((prev) => (prev?.uid === fresh.uid && fresh.version > prev.version ? { ...prev, version: fresh.version } : prev));
555
+ }
556
+ catch {
557
+ // Discard's own retry covers a version this couldn't refresh.
558
+ }
559
+ }
251
560
  // The "Attach files" input is itself `disabled` until `draft` resolves (see its `disabled={!draft}`
252
561
  // below), so this can only ever fire once `draft` is set — no defensive null check needed, matching
253
562
  // this codebase's established pattern for the same class of "always non-null by the time it's
@@ -260,7 +569,9 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
260
569
  for (const file of files) {
261
570
  try {
262
571
  const attachment = await uploadAttachment(draft.uid, file);
572
+ setHasUploads(true);
263
573
  setAttachments((prev) => [...prev, attachment]);
574
+ void refreshDraftVersion(draft.uid);
264
575
  }
265
576
  catch (err) {
266
577
  setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
@@ -270,49 +581,100 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
270
581
  /**
271
582
  * Builds this draft's final body — plaintext, signed-only, or signed+encrypted per
272
583
  * `specs/end-to-end_encryption.md` — then stores it via `assembleDraft()`/`assembleDraftRaw()`.
273
- * Shared by `handleSend()`/`handleScheduleSend()` (and the "Send without encryption" conflict button
274
- * rendered below, via `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
584
+ * Shared by immediate and scheduled sends (and the banners' override buttons rendered below, via
585
+ * `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
275
586
  *
276
- * Returns `"blocked"` (never throws for this case) when encryption was requested but not every
277
- * recipient can currently be encrypted to — the spec's "Multiple Recipients" all-or-nothing rule.
278
- * Sets `encryptionBlocked` as a side effect so the window can render that prompt; callers must stop
279
- * (not send) when they get this back.
587
+ * Returns `"blocked"` (never throws for this case) whenever the message can't go out the way the user
588
+ * asked for — never a silent downgrade to plaintext:
589
+ * encryption was requested (or auto-applies) but not every recipient can currently be encrypted to -
590
+ * the spec's "Multiple Recipients" all-or-nothing rule (sets `encryptionBlocked`); signing/encryption was
591
+ * on offer this session but the keys have since locked, or the mailbox/encryption policy needed to decide
592
+ * couldn't be loaded (sets `securityBlock`, and re-prompts the unlock dialog when the keys are merely
593
+ * locked); or the message would be encrypted but has Bcc recipients - every recipient's certificate goes
594
+ * into one shared envelope, which would disclose the Bcc list to every other recipient, and the send API
595
+ * has no per-recipient-copy submission to avoid that (sets `securityBlock`).
596
+ * Callers must stop (not send) when they get this back. `forcePlaintext` (the user's explicit choice
597
+ * from one of those banners) skips every one of these checks and never encrypts; signing still
598
+ * happens if it's currently possible.
280
599
  *
281
600
  * Discovery (`lookupKeys()`) and the resulting sign/encrypt decision only run at this, the final
282
- * pre-send step — not live as recipients are typed. A compose-time recipient-entry indicator (key
283
- * icons, live discovery as each address is entered) is Phase 4's "Discovery & contacts UI" work, not
284
- * this pass; this still satisfies the spec's "MUST be called lazily at compose time, not on receipt"
285
- * requirement, just without a live UI reflecting it before Send is clicked.
601
+ * pre-send step — `checkRecipientDiscovery()`'s on-blur badges are a best-effort hint only.
286
602
  */
287
603
  async function assembleForSend(toRecipients, ccRecipients, bccRecipients, forcePlaintext) {
288
- const unlocked = getUnlockedKeys(mailboxUid);
289
- // `mailbox` is required to build protected headers (own From address) whenever signing or
290
- // encrypting - not just guarded on the encryption branch below, since a signed-only message
291
- // needs it too. A mailbox fetch failure just means no crypto for this send, never a crash.
292
- const canSign = signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer;
293
- const canEncryptSelf = !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer;
604
+ // Keys are read at the moment they're needed, and again after any await: they can lock (idle
605
+ // timeout, sign-out elsewhere) mid-send, and a destroyed `UnlockedKeys` object must count as locked.
606
+ // Only reachable once a draft exists, which itself requires `mailboxUid` to have resolved.
607
+ function readKeys() {
608
+ const stored = getUnlockedKeys(mailboxUid);
609
+ const unlocked = stored?.destroyed ? undefined : stored;
610
+ return {
611
+ unlocked,
612
+ // `mailbox` is required to build protected headers (own From address) whenever signing or
613
+ // encrypting - not just guarded on the encryption branch below, since a signed-only message
614
+ // needs it too.
615
+ canSign: signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer,
616
+ canEncryptSelf: !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer,
617
+ };
618
+ }
619
+ let keys = readKeys();
294
620
  const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
621
+ // The Sign toggle was shown (and is still checked), but signing can't actually happen any more.
622
+ const signingLost = () => !forcePlaintext && signEnabled && offeredCryptoRef.current.sign && !keys.canSign;
623
+ if (signingLost()) {
624
+ return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
625
+ }
626
+ if (!forcePlaintext && !mailbox) {
627
+ // The mailbox's enrolled keys are what say whether this message could be encrypted at all - with keys
628
+ // that aren't unlocked in this session, a failed mailbox load would otherwise skip encryption silently.
629
+ return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
630
+ }
295
631
  let wantEncrypt = false;
296
632
  let recipientCertDers = [];
297
- if (!forcePlaintext && canEncryptSelf && mailbox && encryptionPolicy && allRecipients.length > 0) {
633
+ // Entered whenever this message could be encrypted at all - including a mailbox with an enrolled
634
+ // encryption key that this session never unlocked: policy may still auto-encrypt, and that must block
635
+ // (and prompt to unlock) rather than quietly send plaintext.
636
+ if (!forcePlaintext && (keys.canEncryptSelf || offeredCryptoRef.current.encrypt || hasEnrolledEncryptionKey)) {
637
+ if (!encryptionPolicy) {
638
+ // Without it, there's no telling whether policy auto-encrypts this message.
639
+ return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
640
+ }
641
+ // Reachable only with `mailbox` loaded (checked above).
298
642
  const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
299
- const lookups = await Promise.all(allRecipients.map((r) => lookupKeys(mailboxUid, r.address).catch(() => undefined)));
300
- const statuses = allRecipients.map((r, i) => resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]));
643
+ const lookups = await Promise.all(allRecipients.map((r) => lookupKeys(mailboxUid, r.address).then((lookup) => ({ lookup }), () => null)));
644
+ keys = readKeys();
645
+ if (signingLost()) {
646
+ return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
647
+ }
648
+ // A failed lookup isn't "no key": treating it as one could send plaintext that policy would encrypt.
649
+ if (lookups.some((result) => !result) && (encryptRequested || policyCanAutoEncrypt(encryptionPolicy))) {
650
+ return blockSend(LOOKUP_UNAVAILABLE_MESSAGE, "Send without encryption", false);
651
+ }
652
+ const statuses = allRecipients.map((r, i) => resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]?.lookup));
301
653
  const decision = decideMessageEncryption(statuses);
302
654
  wantEncrypt = encryptRequested || decision.autoEncrypt;
303
655
  if (wantEncrypt) {
656
+ if (!keys.canEncryptSelf) {
657
+ return blockSend(KEYS_LOCKED_ENCRYPT_MESSAGE, "Send without encryption", !keys.unlocked);
658
+ }
304
659
  if (!decision.canEncryptAll) {
305
660
  setEncryptionBlocked(decision.blockedRecipients);
306
661
  return "blocked";
307
662
  }
308
- recipientCertDers = [unlocked.encryptionCertDer, ...statuses.map((s) => fromBase64(s.encryptCert.publicKey))];
663
+ if (bccRecipients.length > 0) {
664
+ return blockSend(BCC_ENCRYPTED_MESSAGE, "Send without encryption", false);
665
+ }
666
+ recipientCertDers = [keys.unlocked.encryptionCertDer, ...statuses.map((s) => fromBase64(s.encryptCert.publicKey))];
309
667
  }
310
668
  }
669
+ const { canSign } = keys;
311
670
  if ((wantEncrypt || canSign) && attachments.length > 0) {
312
671
  // Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
313
672
  // clearer explanation than that route's generic 400 rather than via a round trip.
314
673
  throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
315
674
  }
675
+ if ((wantEncrypt || canSign) && INLINE_IMAGE_PATTERN.test(html)) {
676
+ throw new ApiRequestError("A signed or encrypted message cannot include inline images yet - remove them before sending.", 400);
677
+ }
316
678
  if (!wantEncrypt && !canSign) {
317
679
  // See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
318
680
  // gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
@@ -320,9 +682,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
320
682
  return assembleDraft(draft.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject, html });
321
683
  }
322
684
  const domain = mailbox.primarySmtpAddress.split("@")[1] ?? "localhost";
685
+ const displayName = mailbox.displayName && !UNSAFE_DISPLAY_NAME_PATTERN.test(mailbox.displayName) ? mailbox.displayName : "";
323
686
  const protectedHeaders = {
324
- from: mailbox.displayName
325
- ? `"${mailbox.displayName.replace(/"/g, '\\"')}" <${mailbox.primarySmtpAddress}>`
687
+ from: displayName
688
+ ? `"${displayName.replace(/"/g, '\\"')}" <${mailbox.primarySmtpAddress}>`
326
689
  : mailbox.primarySmtpAddress,
327
690
  to: toRecipients.map((r) => r.address).join(", "),
328
691
  cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
@@ -331,7 +694,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
331
694
  messageId: `<${crypto.randomUUID()}@${domain}>`,
332
695
  };
333
696
  const bodyContentType = 'text/html; charset="utf-8"';
334
- const signing = canSign ? { certDer: unlocked.signingCertDer, privateKey: unlocked.signingPrivateKey } : undefined;
697
+ const signing = canSign ? { certDer: keys.unlocked.signingCertDer, privateKey: keys.unlocked.signingPrivateKey } : undefined;
335
698
  const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
336
699
  const mimePart = wantEncrypt
337
700
  ? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
@@ -339,86 +702,414 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
339
702
  const rawMime = assembleOutboundMime(outerHeaders, mimePart);
340
703
  return assembleDraftRaw(draft.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
341
704
  }
342
- // Same reasoning as `handleFilesSelected` above: the Send button is itself `disabled` until `draft`
343
- // resolves, so this is never reachable with a null `draft`.
344
- async function handleSend(forcePlaintext = false) {
705
+ /** Records why a send was refused (see `assembleForSend()`), re-prompting the unlock dialog when the
706
+ * cause is keys that are enrolled but locked. */
707
+ function blockSend(message, overrideLabel, keysLocked) {
708
+ setSecurityBlock({ message, overrideLabel });
709
+ if (keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
710
+ void handleUnlockForCrypto();
711
+ }
712
+ return "blocked";
713
+ }
714
+ /**
715
+ * Sends (or, given `scheduledSendTimeIso`, schedules) the draft. Same reasoning as `handleFilesSelected`
716
+ * above: Send and the "Send later" caret are both `disabled` until `draft` resolves, so this is never
717
+ * reachable with a null `draft`. A scheduled send assembles the draft first (same as an immediate send),
718
+ * then sets `scheduledSendTime` on the *freshly assembled* copy before calling `sendMessage()` — `send()`
719
+ * itself is what reads the field and defers relay into Outbox instead of sending immediately (see
720
+ * `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`). A blocked send remembers its schedule
721
+ * time so the banners' override buttons replay it as the same kind of send.
722
+ */
723
+ async function submit(forcePlaintext, scheduledSendTimeIso) {
345
724
  const toRecipients = parseAddresses(to);
346
725
  if (toRecipients.length === 0) {
347
726
  setSendError("At least one recipient is required.");
348
727
  return;
349
728
  }
350
729
  setSending(true);
730
+ sendingRef.current = true;
351
731
  setSendError(null);
732
+ setSecurityBlock(null);
733
+ setEncryptionBlocked(null);
734
+ pendingSaveRef.current = false;
352
735
  try {
736
+ // An autosave already on the wire must never land after (and overwrite) the send's own assembly.
737
+ await saveInFlightRef.current;
353
738
  const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), forcePlaintext);
354
739
  if (assembled === "blocked") {
740
+ setBlockedScheduleIso(scheduledSendTimeIso);
355
741
  return;
356
742
  }
743
+ // The draft now holds exactly this content (possibly signed/encrypted) - don't autosave a
744
+ // plaintext copy over it if the rest of the send fails.
745
+ lastSavedRef.current = `${assembled.uid}:${contentKey}`;
357
746
  const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
358
- await sendMessage(withReceipt.uid);
747
+ await sendMessage(withReceipt.uid, scheduledSendTimeIso ? { scheduledSendTime: scheduledSendTimeIso } : undefined);
748
+ finishedRef.current = true;
359
749
  onClose();
360
750
  }
361
751
  catch (err) {
362
- setSendError(err instanceof ApiRequestError ? err.message : "Could not send this message.");
752
+ const fallback = scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.";
753
+ setSendError(err instanceof ApiRequestError ? err.message : fallback);
363
754
  }
364
755
  finally {
756
+ sendingRef.current = false;
365
757
  setSending(false);
366
758
  }
367
759
  }
368
- // Same reasoning as `handleSend` above (and the "Send later" caret is itself `disabled` alongside
369
- // Send until `draft` resolves) — never reachable with a null `draft`. Assembles the draft first
370
- // (same as an immediate send), then sets `scheduledSendTime` on the *freshly assembled* copy before
371
- // calling `sendMessage()` — `send()` itself is what reads the field and defers relay into Outbox
372
- // instead of sending immediately (see `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`).
373
- async function handleScheduleSend(scheduledSendTimeIso) {
374
- const toRecipients = parseAddresses(to);
760
+ function handleScheduleSend(scheduledSendTimeIso) {
375
761
  setSchedulePickerOpen(false);
376
- if (toRecipients.length === 0) {
377
- setSendError("At least one recipient is required.");
378
- return;
762
+ void submit(false, scheduledSendTimeIso);
763
+ }
764
+ async function handleUnlockForCrypto() {
765
+ try {
766
+ // Only reachable when hasEnrolledSigningKey/hasEnrolledEncryptionKey found a real enrolled key
767
+ // in mailbox.keys (the unlock button below, or blockSend()), so neither is empty/undefined here.
768
+ await requestUnlock(mailbox.uid, mailbox.keys);
769
+ setUnlockRefresh((n) => n + 1);
379
770
  }
380
- setSending(true);
381
- setSendError(null);
771
+ catch {
772
+ // User dismissed the unlock dialog - nothing to do, the toggles below simply stay hidden.
773
+ }
774
+ }
775
+ /**
776
+ * Saves the window's current recipients/subject/body onto its server draft via `assembleDraft()` (the
777
+ * same plaintext assembly a normal send uses). Chained after any save already on the wire - two
778
+ * overlapping assemblies of the same draft race on its version and the later one is rejected - and reads
779
+ * `latestRef` only once it actually runs, so it saves what was last typed (skipping the request when the
780
+ * save before it already stored exactly that). Whether a save may happen at all is re-checked at that same
781
+ * moment: while it waited, the message may have become one that must not be stored as plaintext (Encrypt
782
+ * turned on, a recipient that auto-encrypts), or the window may have started sending or finished - then it
783
+ * resolves `{ skipped: true }` without a request (`message` then being the previous save's result). Never
784
+ * rejects; `message` is `undefined` when the save failed.
785
+ */
786
+ function saveDraftNow() {
787
+ const target = latestRef.current.draft;
788
+ const previous = saveInFlightRef.current;
789
+ pendingSaveRef.current = false;
790
+ setSaveStatus("saving");
791
+ const outcome = (async () => {
792
+ const before = await previous;
793
+ const current = latestRef.current;
794
+ if (current.autosaveSuppressed || sendingRef.current || finishedRef.current) {
795
+ setSaveStatus("idle");
796
+ // Still the newest copy the server holds, for the version a later delete needs.
797
+ return { skipped: true, message: before };
798
+ }
799
+ const savedKey = `${target.uid}:${current.contentKey}`;
800
+ if (before?.uid === target.uid && lastSavedRef.current === savedKey) {
801
+ setSaveStatus("saved");
802
+ return { message: before };
803
+ }
804
+ try {
805
+ const saved = await assembleDraft(target.uid, {
806
+ to: parseAddresses(current.to),
807
+ cc: parseAddresses(current.cc),
808
+ bcc: parseAddresses(current.bcc),
809
+ subject: current.subject,
810
+ html: current.html,
811
+ });
812
+ lastSavedRef.current = savedKey;
813
+ saveErrorRef.current = null;
814
+ // A From switch may have replaced the draft while this was in flight - never resurrect it,
815
+ // but do keep its superseded copy's version current so its pending delete still matches.
816
+ setDraft((prev) => (prev?.uid === saved.uid ? saved : prev));
817
+ const superseded = supersededDraftsRef.current.find((d) => d.uid === saved.uid);
818
+ if (superseded) {
819
+ superseded.version = saved.version;
820
+ }
821
+ setSaveStatus("saved");
822
+ return { message: saved };
823
+ }
824
+ catch (err) {
825
+ saveErrorRef.current = err instanceof ApiRequestError ? err.message : "the server couldn't be reached";
826
+ setSaveStatus("error");
827
+ return {};
828
+ }
829
+ })();
830
+ saveInFlightRef.current = outcome.then((result) => result.message);
831
+ return outcome;
832
+ }
833
+ /** Keeps the window open after its content couldn't be saved, offering Discard or Keep editing. */
834
+ function showSaveFailedPrompt() {
835
+ setClosePrompt({
836
+ title: "Couldn't save this draft",
837
+ message: `It couldn't be saved (${saveErrorRef.current.replace(/\.$/, "")}). Keep editing and try again, or discard it.`,
838
+ });
839
+ }
840
+ /**
841
+ * Deletes `current` (this window's server draft) once any save already on the wire has landed, so the delete
842
+ * carries the draft's current version. A delete rejected for a stale version (404/409: e.g. an attachment
843
+ * upload bumped it) is retried once with the version the server holds - and a draft the server no longer has
844
+ * counts as deleted. The retry only happens while the server copy is still an unsent draft in the same folder.
845
+ * Rejects when the draft couldn't be deleted.
846
+ */
847
+ async function deleteDraft(current) {
848
+ const saved = await saveInFlightRef.current;
849
+ const version = Math.max(current.version, saved?.uid === current.uid ? saved.version : 0);
382
850
  try {
383
- const assembled = await assembleForSend(toRecipients, parseAddresses(cc), parseAddresses(bcc), false);
384
- if (assembled === "blocked") {
851
+ await deleteMessage(current.uid, version);
852
+ }
853
+ catch (err) {
854
+ if (!(err instanceof ApiRequestError) || (err.status !== 404 && err.status !== 409)) {
855
+ throw err;
856
+ }
857
+ const fresh = await getMessage(current.uid).catch((getErr) => {
858
+ if (getErr instanceof ApiRequestError && getErr.status === 404) {
859
+ return null;
860
+ }
861
+ throw getErr;
862
+ });
863
+ if (!fresh) {
385
864
  return;
386
865
  }
387
- const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
388
- const scheduled = await setMessageScheduledSendTime(withReceipt, scheduledSendTimeIso);
389
- await sendMessage(scheduled.uid);
866
+ // The version changed because another window sent or scheduled it - that message isn't ours to delete.
867
+ if (!isStillDraft(fresh, current.folderUid)) {
868
+ throw new ApiRequestError(NO_LONGER_A_DRAFT_MESSAGE, 409);
869
+ }
870
+ await deleteMessage(fresh.uid, fresh.version);
871
+ }
872
+ }
873
+ /**
874
+ * Discards the window's draft and closes it. With nothing the user would lose (a blank draft) it closes
875
+ * straight away and deletes the draft in the background; otherwise it closes only once the draft is
876
+ * deleted, and a delete that fails keeps the window open with the error.
877
+ */
878
+ async function discardNow() {
879
+ finishedRef.current = true;
880
+ pendingSaveRef.current = false;
881
+ setClosePrompt(null);
882
+ setDiscardError(null);
883
+ const current = draft;
884
+ if (!current || !hasUserContent) {
885
+ if (current) {
886
+ void deleteDraft(current).catch(() => undefined);
887
+ }
390
888
  onClose();
889
+ return;
890
+ }
891
+ setClosing(true);
892
+ try {
893
+ await deleteDraft(current);
391
894
  }
392
895
  catch (err) {
393
- setSendError(err instanceof ApiRequestError ? err.message : "Could not schedule this message.");
896
+ finishedRef.current = false;
897
+ setClosing(false);
898
+ setDiscardError(`Couldn't discard this draft: ${err instanceof ApiRequestError ? err.message : "the server couldn't be reached."}`);
899
+ return;
394
900
  }
395
- finally {
396
- setSending(false);
901
+ onClose();
902
+ }
903
+ /** "Discard draft": confirms first whenever there's anything the user would lose. */
904
+ function handleDiscard() {
905
+ if (hasUserContent) {
906
+ setClosePrompt({ title: DISCARD_TITLE, message: "This permanently deletes this draft and everything you've written in it." });
907
+ }
908
+ else {
909
+ void discardNow();
910
+ }
911
+ }
912
+ /** "Close": keeps the draft (saving any unsaved edits first, and closing only once that save succeeded) -
913
+ * unless there's nothing in it worth keeping, or it can't be saved because it's headed for encryption, or
914
+ * whether it is can't be told yet (settings loading or unavailable, recipient lookups pending): those need
915
+ * the same confirmation as a discard. A failed save keeps the window open with Discard / Keep editing.
916
+ * Close and Discard are both disabled while a send is in progress: deleting the draft, or saving a
917
+ * plaintext copy over the message the send just assembled, mid-send would lose or leak it. */
918
+ async function handleClose() {
919
+ if (!hasUserContent) {
920
+ void discardNow();
921
+ return;
922
+ }
923
+ if (encryptionDecided) {
924
+ setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
925
+ return;
926
+ }
927
+ if (cryptoCheckUnavailable) {
928
+ setClosePrompt({ title: DISCARD_TITLE, message: CRYPTO_UNAVAILABLE_MESSAGE, retry: true });
929
+ return;
930
+ }
931
+ if (awaitingRecipients) {
932
+ setClosePrompt({ title: DISCARD_TITLE, message: NO_RECIPIENTS_CLOSE_MESSAGE });
933
+ return;
934
+ }
935
+ if (encryptionUndetermined) {
936
+ // A recipient typed but never blurred has no lookup running yet.
937
+ checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
938
+ setClosePrompt({ title: DISCARD_TITLE, message: CHECKING_CLOSE_MESSAGE });
939
+ return;
940
+ }
941
+ if (!draft || !contentReady) {
942
+ saveErrorRef.current = draftError ?? "the draft hasn't been created yet";
943
+ showSaveFailedPrompt();
944
+ return;
945
+ }
946
+ if (lastSavedRef.current !== `${draft.uid}:${contentKey}`) {
947
+ setClosing(true);
948
+ const outcome = await saveDraftNow();
949
+ setClosing(false);
950
+ if (outcome.skipped) {
951
+ // It became a message that mustn't be saved as plaintext while waiting on an earlier save.
952
+ setClosePrompt({ title: DISCARD_TITLE, message: latestRef.current.encryptionDecided ? ENCRYPTED_CLOSE_MESSAGE : CHECKING_CLOSE_MESSAGE });
953
+ return;
954
+ }
955
+ if (!outcome.message) {
956
+ showSaveFailedPrompt();
957
+ return;
958
+ }
397
959
  }
960
+ finishedRef.current = true;
961
+ onClose();
962
+ }
963
+ function retryCryptoContext() {
964
+ setClosePrompt(null);
965
+ setCryptoRetryToken((n) => n + 1);
966
+ lookupFailuresRef.current = new Map();
967
+ setExhaustedLookups({});
968
+ setLookupRetryToken((n) => n + 1);
398
969
  }
399
970
  // A plain read from keySession.ts's module-level session store, not React state - see that module's
400
971
  // own doc comment. Cheap enough to read fresh on every render rather than caching in state.
401
- const unlockedKeys = getUnlockedKeys(mailboxUid);
972
+ const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
973
+ const hasEnrolledSigningKey = !!findActivePublicKey(mailbox?.keys ?? [], "sign");
974
+ const hasEnrolledEncryptionKey = !!findActivePublicKey(mailbox?.keys ?? [], "encrypt");
975
+ // This mailbox has a real signing/encryption key on file, but this session hasn't unlocked it yet -
976
+ // the sign/encrypt toggles below stay hidden until it has, so this is the only way to reach them
977
+ // without first stumbling into Mail generally (which no longer force-prompts on its own - see
978
+ // `MailShell`'s `blocking={false}`). `getUnlockedKeys()` is re-read as a plain module value above, so
979
+ // bumping `unlockRefresh` (after a successful unlock, or on any key-session event for this mailbox -
980
+ // see the `subscribeKeySession()` effect) is what makes this line (and the toggles it gates) reflect
981
+ // it on the next render.
982
+ const needsUnlockForCrypto = (hasEnrolledSigningKey || hasEnrolledEncryptionKey) && !unlockedKeys;
983
+ // Keys locking (idle timeout, logout elsewhere, ...) or unlocking from another window must update the
984
+ // toggles/unlock affordance right away, not on whatever unrelated render happens next.
985
+ useEffect(() => subscribeKeySession((event) => {
986
+ if (event.mailboxUid === mailboxUid) {
987
+ setUnlockRefresh((n) => n + 1);
988
+ }
989
+ }), [mailboxUid]);
990
+ useEffect(() => {
991
+ offeredCryptoRef.current = {
992
+ sign: offeredCryptoRef.current.sign || !!unlockedKeys?.signingPrivateKey,
993
+ encrypt: offeredCryptoRef.current.encrypt || !!unlockedKeys?.encryptionPrivateKey,
994
+ };
995
+ });
996
+ // Autosave. "User content" is anything beyond what compose opened with (prefilled recipients/subject,
997
+ // seeded signature/quote) - an untouched window has nothing worth saving or confirming a discard of.
998
+ const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
999
+ const baselineKey = JSON.stringify([initialTo ?? "", initialCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
1000
+ const hasUserContent = contentKey !== baselineKey || hasUploads;
1001
+ // A message headed for encryption is never autosaved: the draft would store its plaintext server-side,
1002
+ // defeating end-to-end encryption. Until it's known not to be, it isn't either: while the mailbox (whose
1003
+ // enrolled keys say whether encryption is possible at all) or the encryption policy hasn't loaded, and -
1004
+ // whenever this message could be encrypted (an unlocked or enrolled encryption key) - while the policy is
1005
+ // missing or any current recipient's lookup is still pending (or failed, or hasn't run) - and while there are no
1006
+ // recipients at all when policy auto-encrypts some tier, since the first one added may turn encryption on.
1007
+ const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
1008
+ const currentStatuses = currentAddresses.map((address) => recipientStatuses[address]);
1009
+ const encryptionPossible = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
1010
+ /** Could be auto-encrypted, but has no recipients yet to decide by. */
1011
+ const awaitingRecipients = encryptionPossible && !!encryptionPolicy && currentAddresses.length === 0 && policyCanAutoEncrypt(encryptionPolicy);
1012
+ const encryptionUndetermined = !cryptoContextReady || !mailbox || awaitingRecipients || (encryptionPossible && (!encryptionPolicy || currentStatuses.some((s) => !s)));
1013
+ /** Known to be headed for encryption: requested, or policy auto-encrypts it. */
1014
+ const encryptionDecided = encryptRequested || (!encryptionUndetermined && encryptionPossible && decideMessageEncryption(currentStatuses).autoEncrypt);
1015
+ const autosaveSuppressed = encryptionDecided || encryptionUndetermined;
1016
+ /** Loading the mailbox/policy failed (retries may still be pending) and this message's encryption hinges on it, or
1017
+ * a current recipient's key lookup kept failing. */
1018
+ const cryptoCheckUnavailable = (cryptoContextReady && cryptoLoadFailed && (!mailbox || (encryptionPossible && !encryptionPolicy))) ||
1019
+ (encryptionPossible && currentAddresses.some((address) => exhaustedLookups[address] && !recipientStatuses[address]));
1020
+ const latestRef = useRef({ draft, to, cc, bcc, subject, html, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided });
1021
+ latestRef.current = { draft, to, cc, bcc, subject, html, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided };
1022
+ useEffect(() => {
1023
+ const due = !!draft &&
1024
+ contentReady &&
1025
+ cryptoContextReady &&
1026
+ !sending &&
1027
+ !autosaveSuppressed &&
1028
+ hasUserContent &&
1029
+ lastSavedRef.current !== `${draft.uid}:${contentKey}`;
1030
+ pendingSaveRef.current = due;
1031
+ if (!due) {
1032
+ return;
1033
+ }
1034
+ const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
1035
+ return () => clearTimeout(timer);
1036
+ }, [draft, contentReady, cryptoContextReady, sending, autosaveSuppressed, hasUserContent, contentKey]);
1037
+ // Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
1038
+ // right away rather than losing it.
1039
+ useEffect(() => () => {
1040
+ if (pendingSaveRef.current && !finishedRef.current) {
1041
+ void saveDraftNow();
1042
+ }
1043
+ }, []);
1044
+ /** Saves an edit still waiting on the debounce now, and resolves once that save - or one already on the
1045
+ * wire - has settled, to whether the window's content is saved. A save that failed keeps the window open
1046
+ * with Discard / Keep editing. A send clears `pendingSaveRef` before it starts, so this never saves mid-send. */
1047
+ async function flushPendingSave() {
1048
+ if (pendingSaveRef.current && !finishedRef.current) {
1049
+ const outcome = await saveDraftNow();
1050
+ if (outcome.skipped) {
1051
+ // Not saved, but not a failure either: it may be headed for encryption (or was sent/discarded meanwhile).
1052
+ return finishedRef.current;
1053
+ }
1054
+ if (!outcome.message) {
1055
+ showSaveFailedPrompt();
1056
+ }
1057
+ return !!outcome.message;
1058
+ }
1059
+ await saveInFlightRef.current;
1060
+ const latest = latestRef.current;
1061
+ if (finishedRef.current || !latest.hasUserContent || (!!latest.draft && lastSavedRef.current === `${latest.draft.uid}:${latest.contentKey}`)) {
1062
+ return true;
1063
+ }
1064
+ // Unsaved because the last save failed (rather than, say, an encrypted message never being saved).
1065
+ if (saveErrorRef.current) {
1066
+ showSaveFailedPrompt();
1067
+ }
1068
+ return false;
1069
+ }
1070
+ // Sign Out (AppShell) waits for this before ending the session.
1071
+ useEffect(() => registerComposeFlush(flushPendingSave), []);
1072
+ // Leaving the page (reload, closing the tab, following a link) with anything not saved: start a save that's
1073
+ // waiting on the debounce straight away and ask the browser to confirm, so the last couple of seconds of
1074
+ // typing (or content that can't be saved as a draft, or failed to) aren't lost silently. Never while signing
1075
+ // out - that navigation must not be cancellable.
1076
+ useEffect(() => {
1077
+ function handleBeforeUnload(event) {
1078
+ if (finishedRef.current || sendingRef.current || isSigningOut()) {
1079
+ return;
1080
+ }
1081
+ const latest = latestRef.current;
1082
+ const unsaved = pendingSaveRef.current ||
1083
+ latest.saveStatus === "saving" ||
1084
+ (latest.hasUserContent && lastSavedRef.current !== `${latest.draft?.uid}:${latest.contentKey}`);
1085
+ if (!unsaved) {
1086
+ return;
1087
+ }
1088
+ void flushPendingSave();
1089
+ event.preventDefault();
1090
+ event.returnValue = "";
1091
+ }
1092
+ window.addEventListener("beforeunload", handleBeforeUnload);
1093
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
1094
+ }, []);
402
1095
  const title = subject.trim() || "New Message";
403
1096
  const titleId = `compose-title-${id}`;
1097
+ const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [closePrompt?.retry && (_jsx(Button, { type: "button", onClick: retryCryptoContext, className: "!w-auto", children: "Retry" })), _jsx(Button, { type: "button", onClick: () => void discardNow(), disabled: sending || closing, className: "!w-auto", children: "Discard" }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => setClosePrompt(null), className: "!w-auto", children: "Keep editing" })] })] }));
404
1098
  if (minimized) {
405
- return (_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: onClose, icon: HiOutlineXMark })] })] }) }));
1099
+ return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: sending || closing })] })] }) }), discardModal] }));
406
1100
  }
407
- return (_jsxs("div", { ref: windowRef, role: "dialog", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
408
- "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
409
- isMobile
410
- ? "fixed inset-0 w-full h-full rounded-none border-0"
411
- : ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
412
- ].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: onClose, icon: HiOutlineXMark })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError) && (_jsxs("div", { className: "px-3 pt-2", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError })] })), encryptionBlocked && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsxs("p", { className: "mb-2", children: ["This message can\u2019t be encrypted for everyone: ", encryptionBlocked.map((r) => r.address).join(", "), encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file", ". Remove ", encryptionBlocked.length > 1 ? "these recipients" : "this recipient", " from To/Cc/Bcc, or send the whole message in plaintext."] }), _jsx(Button, { type: "button", onClick: () => {
413
- setEncryptionBlocked(null);
414
- void handleSend(true);
415
- }, children: "Send without encryption" })] }) })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx("input", { id: `compose-to-${id}`, type: "text", className: FIELD_INPUT, value: to, onChange: (e) => setTo(e.target.value), onBlur: () => checkRecipientDiscovery(parseAddresses(to)) }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx("input", { id: `compose-cc-${id}`, type: "text", className: FIELD_INPUT, value: cc, onChange: (e) => setCc(e.target.value), onBlur: () => checkRecipientDiscovery(parseAddresses(cc)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx("input", { id: `compose-bcc-${id}`, type: "text", className: FIELD_INPUT, value: bcc, onChange: (e) => setBcc(e.target.value), onBlur: () => checkRecipientDiscovery(parseAddresses(bcc)) })] })] })), (() => {
416
- const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
417
- .map((r) => recipientStatuses[r.address])
418
- .filter((status) => !!status);
419
- if (knownRecipients.length === 0) {
420
- return null;
421
- }
422
- return (_jsx("ul", { className: "flex flex-wrap gap-1.5 px-3 pt-1.5", "aria-label": "Recipient encryption availability", children: knownRecipients.map((status) => (_jsxs("li", { className: `text-xs font-medium py-0.5 px-2 rounded-pill ${status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"}`, children: [status.address, " ", status.canEncrypt ? "supports encryption" : "no encryption key found"] }, status.address))) }));
423
- })(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && _jsx(RichTextEditor, { value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage }) }), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => handleSend(), disabled: !draft || sending || !cryptoContextReady, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: sending ? "Sending…" : "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || sending || !cryptoContextReady, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: onClose, className: "ml-auto w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }));
1101
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
1102
+ "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1103
+ isMobile
1104
+ ? "fixed inset-0 w-full h-full rounded-none border-0"
1105
+ : ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1106
+ ].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: sending || closing })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), cryptoCheckUnavailable && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: CRYPTO_UNAVAILABLE_MESSAGE }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: retryCryptoContext, children: "Retry" })] }) })), encryptionBlocked && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsxs("p", { className: "mb-2", children: ["This message can\u2019t be encrypted for everyone: ", encryptionBlocked.map((r) => r.address).join(", "), encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file", ". Remove ", encryptionBlocked.length > 1 ? "these recipients" : "this recipient", " from To/Cc/Bcc, or send the whole message in plaintext."] }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: "Send without encryption" })] }) })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`, value: mailboxUid ?? "", disabled: hasUploads || sending, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx("input", { id: `compose-to-${id}`, type: "text", className: FIELD_INPUT, value: to, onChange: (e) => setTo(e.target.value), onBlur: () => checkRecipientDiscovery(parseAddresses(to)) }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx("input", { id: `compose-cc-${id}`, type: "text", className: FIELD_INPUT, value: cc, onChange: (e) => setCc(e.target.value), onBlur: () => checkRecipientDiscovery(parseAddresses(cc)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx("input", { id: `compose-bcc-${id}`, type: "text", className: FIELD_INPUT, value: bcc, onChange: (e) => setBcc(e.target.value), onBlur: () => checkRecipientDiscovery(parseAddresses(bcc)) })] })] })), (() => {
1107
+ const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
1108
+ .map((r) => recipientStatuses[r.address])
1109
+ .filter((status) => !!status);
1110
+ if (knownRecipients.length === 0) {
1111
+ return null;
1112
+ }
1113
+ return (_jsx("ul", { className: "flex flex-wrap gap-1.5 px-3 pt-1.5", "aria-label": "Recipient encryption availability", children: knownRecipients.map((status) => (_jsxs("li", { className: `text-xs font-medium py-0.5 px-2 rounded-pill ${status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"}`, children: [status.address, " ", status.canEncrypt ? "supports encryption" : "no encryption key found"] }, status.address))) }));
1114
+ })(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && _jsx(RichTextEditor, { value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage }) }), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), disabled: !draft || sending || closing || !cryptoContextReady, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: sending ? "Sending…" : "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || sending || closing || !cryptoContextReady, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: sending || closing, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
424
1115
  }