@rapidmx/web-client 0.3.0 → 0.4.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 (102) hide show
  1. package/apps/admin/branding/index.tsx +39 -404
  2. package/apps/admin/domains/[uid].tsx +92 -259
  3. package/apps/admin/encryption-policy/index.tsx +19 -0
  4. package/apps/admin/index.tsx +100 -82
  5. package/apps/admin/mailbox-policy/index.tsx +19 -0
  6. package/apps/admin/mailboxes/new/index.tsx +28 -291
  7. package/apps/admin/plugins/index.tsx +15 -0
  8. package/apps/admin/retention-policy/index.tsx +39 -132
  9. package/apps/admin/setup/index.tsx +15 -0
  10. package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
  11. package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
  12. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
  13. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
  14. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  15. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
  16. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
  17. package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
  18. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
  19. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
  20. package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
  21. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  22. package/apps/shared/components/calendar/EventModal.tsx +40 -4
  23. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  24. package/apps/shared/components/contacts/ContactForm.tsx +38 -4
  25. package/apps/shared/components/layout/AppShell.tsx +198 -169
  26. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
  27. package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
  28. package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
  29. package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
  30. package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
  31. package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
  32. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  33. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  34. package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
  35. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  36. package/apps/shared/search/localIndexBuilder.ts +179 -0
  37. package/apps/shared/search/localIndexKey.ts +27 -0
  38. package/apps/shared/search/localIndexRpcClient.ts +116 -0
  39. package/apps/shared/search/localIndexSchema.ts +227 -0
  40. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  41. package/apps/shared/search/localIndexVFS.ts +235 -0
  42. package/apps/shared/search/localIndexWorker.ts +464 -0
  43. package/apps/shared/search/searchTier2.ts +79 -0
  44. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  45. package/apps/www/calendar/index.tsx +31 -21
  46. package/apps/www/contacts/index.tsx +33 -6
  47. package/apps/www/index.tsx +661 -106
  48. package/apps/www/messages/[uid].tsx +5 -1
  49. package/apps/www/settings/encryption/index.tsx +63 -1
  50. package/apps/www/settings/sharing/index.tsx +271 -0
  51. package/apps/www/tasks/index.tsx +53 -4
  52. package/dist/apps/admin/branding/index.js +4 -98
  53. package/dist/apps/admin/domains/[uid].js +5 -68
  54. package/dist/apps/admin/encryption-policy/index.js +8 -0
  55. package/dist/apps/admin/index.js +14 -1
  56. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  57. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  58. package/dist/apps/admin/plugins/index.js +6 -0
  59. package/dist/apps/admin/retention-policy/index.js +3 -36
  60. package/dist/apps/admin/setup/index.js +6 -0
  61. package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
  62. package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
  63. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
  64. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
  65. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  66. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
  67. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
  68. package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
  69. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
  70. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
  71. package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
  72. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  73. package/dist/apps/shared/components/calendar/EventModal.js +16 -4
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  75. package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
  76. package/dist/apps/shared/components/layout/AppShell.js +30 -7
  77. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
  78. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
  79. package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
  80. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
  81. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
  83. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  84. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
  86. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  87. package/dist/apps/shared/search/localIndexBuilder.js +153 -0
  88. package/dist/apps/shared/search/localIndexKey.js +25 -0
  89. package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
  90. package/dist/apps/shared/search/localIndexSchema.js +179 -0
  91. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  92. package/dist/apps/shared/search/localIndexVFS.js +230 -0
  93. package/dist/apps/shared/search/localIndexWorker.js +341 -0
  94. package/dist/apps/shared/search/searchTier2.js +38 -0
  95. package/dist/apps/www/calendar/index.js +14 -12
  96. package/dist/apps/www/contacts/index.js +15 -6
  97. package/dist/apps/www/index.js +496 -94
  98. package/dist/apps/www/messages/[uid].js +5 -1
  99. package/dist/apps/www/settings/encryption/index.js +30 -2
  100. package/dist/apps/www/settings/sharing/index.js +128 -0
  101. package/dist/apps/www/tasks/index.js +27 -5
  102. package/package.json +3 -2
@@ -4,6 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useState } from "react";
7
+ import { HiOutlineLockClosed } from "react-icons/hi2";
7
8
  import DOMPurify from "dompurify";
8
9
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
10
  import { approveReceipt, archiveMessage, attachmentContentUrl, cancelScheduledSend, classifyMessage, declineReceipt, getMessageRawContent, recallMessage, setMessageLabels, } from "@rapidmx/react-shared/mail/mailApi.js";
@@ -12,6 +13,8 @@ import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
12
13
  import { evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
13
14
  import { isLikelyMailingList } from "@rapidmx/react-shared/crypto/composeSecurity.js";
14
15
  import { useCompose } from "./compose/ComposeContext.js";
16
+ import { useMailShell } from "./layout/MailShell.js";
17
+ import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
15
18
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
16
19
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
17
20
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
@@ -68,6 +71,12 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
68
71
  const [receiptBusy, setReceiptBusy] = useState(null);
69
72
  const [receiptError, setReceiptError] = useState(null);
70
73
  const [security, setSecurity] = useState(null);
74
+ const { mailboxes } = useMailShell();
75
+ const { requestUnlock } = useUnlockPrompt();
76
+ // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
77
+ // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
78
+ // state; see keySession.ts's own doc comment).
79
+ const [unlockRefresh, setUnlockRefresh] = useState(0);
71
80
  // Evaluates every message's security state, not just ones flagged `encrypted` - a detached
72
81
  // `multipart/signed` message needs its raw MIME read too (a sanitized HTML body never carries the
73
82
  // signature part) to tell "Signed & verified" apart from plain "Unprotected". A fetch/parse failure
@@ -95,7 +104,23 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
95
104
  return () => {
96
105
  cancelled = true;
97
106
  };
98
- }, [message?.uid]);
107
+ }, [message?.uid, unlockRefresh]);
108
+ // Offered only when this device genuinely has no unlocked session for this message's mailbox at all
109
+ // (as opposed to being unlocked but still unable to decrypt - a wrong/since-rotated key, which
110
+ // re-unlocking the same session can't fix) - see `evaluateMessageSecurity()`'s own doc comment on why
111
+ // `decryptError` alone can't distinguish those two cases.
112
+ async function handleUnlockToView() {
113
+ // Only reachable via the button below, which never renders while `message` is null.
114
+ const mailboxUid = message.mailboxUid;
115
+ const mailboxKeys = mailboxes.find((mb) => mb.uid === mailboxUid)?.keys ?? [];
116
+ try {
117
+ await requestUnlock(mailboxUid, mailboxKeys);
118
+ setUnlockRefresh((n) => n + 1);
119
+ }
120
+ catch {
121
+ // User dismissed the unlock dialog - security state stays exactly as it was.
122
+ }
123
+ }
99
124
  if (!message) {
100
125
  return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a message to read it." });
101
126
  }
@@ -245,7 +270,7 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
245
270
  .filter((l) => !!l)
246
271
  .map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), isInbox && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 mt-2", children: [_jsx(Button, { type: "button", variant: "text", loading: classifying, disabled: classifying, onClick: () => handleClassify(message.inferenceClassification === "other" ? "focused" : "other"), children: message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other" }), _jsxs("label", { className: "flex items-center gap-1.5 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: alwaysForSender, onChange: (e) => setAlwaysForSender(e.target.checked) }), "Always for this sender"] })] })), classifyError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: classifyError }) })), ["delivery", "read"]
247
272
  .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
248
- .map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [message.from.displayName || message.from.address, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: receiptError }) })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 mt-3", children: attachments.map((attachment) => (_jsx("li", { children: _jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] }) }, attachment.uid))) }))] }), security?.decryptError && (_jsx("div", { className: "px-4 pt-2", children: _jsx(Alert, { children: security.decryptError }) })), security?.html !== undefined ? (_jsx("iframe", { title: message.subject || "Message content", srcDoc: DOMPurify.sanitize(security.html), sandbox: "", className: "flex-1 w-full border-0" }, message.uid)) : (_jsx("iframe", { title: message.subject || "Message content", src: `/api/mail/messages/${encodeURIComponent(message.uid)}/content`, sandbox: "", className: "flex-1 w-full border-0" }, message.uid)), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsxs(Modal, { open: labelsOpen, onClose: () => setLabelsOpen(false), title: "Labels", children: [labelsError && _jsx(Alert, { children: labelsError }), _jsx("ul", { className: "flex flex-col gap-1", children: (labels ?? []).map((l) => {
273
+ .map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [message.from.displayName || message.from.address, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: receiptError }) })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 mt-3", children: attachments.map((attachment) => (_jsx("li", { children: _jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] }) }, attachment.uid))) }))] }), security?.decryptError && (_jsxs("div", { className: "px-4 pt-2", children: [_jsx(Alert, { children: security.decryptError }), message && !getUnlockedKeys(message.mailboxUid) && (_jsxs("button", { type: "button", onClick: handleUnlockToView, className: "mt-1.5 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 view this message"] }))] })), security?.html !== undefined ? (_jsx("iframe", { title: message.subject || "Message content", srcDoc: DOMPurify.sanitize(security.html), sandbox: "", className: "flex-1 w-full border-0" }, message.uid)) : (_jsx("iframe", { title: message.subject || "Message content", src: `/api/mail/messages/${encodeURIComponent(message.uid)}/content`, sandbox: "", className: "flex-1 w-full border-0" }, message.uid)), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsxs(Modal, { open: labelsOpen, onClose: () => setLabelsOpen(false), title: "Labels", children: [labelsError && _jsx(Alert, { children: labelsError }), _jsx("ul", { className: "flex flex-col gap-1", children: (labels ?? []).map((l) => {
249
274
  const checked = message.labelUids?.includes(l.uid) ?? false;
250
275
  return (_jsx("li", { children: _jsxs("label", { className: "flex items-center gap-2 py-1.5 px-1 text-sm rounded-sm hover:bg-surface-alt", children: [_jsx("input", { type: "checkbox", checked: checked, disabled: togglingLabelUid === l.uid, onChange: () => handleToggleLabel(l.uid) }), _jsx("span", { className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }) }, l.uid));
251
276
  }) })] })] }));
@@ -18,7 +18,7 @@ export function useCompose() {
18
18
  * opening Compose from Contacts' "Email" action, for instance, overlays the window on top of whatever
19
19
  * app is currently showing, exactly like opening it from Mail's own sidebar button.
20
20
  */
21
- export default function ComposeProvider({ children }) {
21
+ export default function ComposeProvider({ children, userUid }) {
22
22
  const [sessions, setSessions] = useState([]);
23
23
  const isMobile = useIsMobile();
24
24
  function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning }) {
@@ -52,5 +52,5 @@ export default function ComposeProvider({ children }) {
52
52
  // non-minimized one) once whatever's currently "on top" is closed or minimized.
53
53
  const lastNonMinimizedId = isMobile ? [...sessions].reverse().find((s) => !s.minimized)?.id : undefined;
54
54
  const visibleSessions = isMobile ? sessions.filter((s) => s.minimized || s.id === lastNonMinimizedId) : sessions;
55
- return (_jsxs(ComposeContext.Provider, { value: value, children: [children, visibleSessions.length > 0 && (_jsx("div", { className: "fixed bottom-0 right-6 flex items-end gap-3 z-50", children: visibleSessions.map((session) => (_jsx(ComposeWindow, { session: session, onClose: () => closeCompose(session.id), onToggleMinimize: () => toggleMinimize(session.id) }, session.id))) }))] }));
55
+ return (_jsxs(ComposeContext.Provider, { value: value, children: [children, visibleSessions.length > 0 && (_jsx("div", { className: "fixed bottom-0 right-6 flex items-end gap-3 z-50", children: visibleSessions.map((session) => (_jsx(ComposeWindow, { session: session, userUid: userUid, onClose: () => closeCompose(session.id), onToggleMinimize: () => toggleMinimize(session.id) }, session.id))) }))] }));
56
56
  }
@@ -4,13 +4,14 @@ 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, listFolders, listMailboxes, sendMessage, setMessageRequestReceipt, setMessageScheduledSendTime, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
10
10
  import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
11
11
  import { decideMessageEncryption, resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
12
12
  import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
13
- import { getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
13
+ import { findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
14
+ import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
14
15
  import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
15
16
  import { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
16
17
  import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
@@ -53,9 +54,22 @@ function HeaderButton({ label, onClick, icon: Icon }) {
53
54
  * see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
54
55
  * once.
55
56
  */
56
- export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
57
- const { id, mailboxUid, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, suppressSigning, minimized } = session;
57
+ export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid }) {
58
+ const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, suppressSigning, minimized } = session;
59
+ // The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
60
+ // compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
61
+ // Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
62
+ const [fromMailboxUid, setFromMailboxUid] = useState(session.mailboxUid);
63
+ const mailboxUid = fromMailboxUid;
64
+ const [mailboxes, setMailboxes] = useState([]);
65
+ // Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
66
+ // Drafts folder - so once anything has been uploaded the sender can no longer be switched.
67
+ const [hasUploads, setHasUploads] = useState(false);
58
68
  const isMobile = useIsMobile();
69
+ const { requestUnlock } = useUnlockPrompt();
70
+ // Bumped after a successful on-demand unlock to force a re-render - `getUnlockedKeys()` below is a
71
+ // plain read from a module-level store, not React state, so nothing else would pick up the change.
72
+ const [, setUnlockRefresh] = useState(0);
59
73
  const windowRef = useRef(null);
60
74
  const [draftsFolderUid, setDraftsFolderUid] = useState();
61
75
  const [folderError, setFolderError] = useState(null);
@@ -98,6 +112,31 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
98
112
  // send that happens to race this fetch can't silently skip encryption the spec says should apply.
99
113
  const [cryptoContextReady, setCryptoContextReady] = useState(false);
100
114
  useEffect(() => {
115
+ let cancelled = false;
116
+ listMailboxes({ limit: 100 })
117
+ .then((result) => {
118
+ if (cancelled) {
119
+ return;
120
+ }
121
+ setMailboxes(result);
122
+ setFromMailboxUid((current) => current ?? (result.find((mb) => mb.ownerUserUid === userUid) ?? result[0])?.uid);
123
+ })
124
+ .catch((err) => {
125
+ // Only fatal when there's no mailbox to fall back on - a session that already names one just
126
+ // loses the From picker.
127
+ if (!cancelled && !session.mailboxUid) {
128
+ setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
129
+ }
130
+ });
131
+ return () => {
132
+ cancelled = true;
133
+ };
134
+ // eslint-disable-next-line react-hooks/exhaustive-deps
135
+ }, []);
136
+ useEffect(() => {
137
+ if (!mailboxUid) {
138
+ return;
139
+ }
101
140
  let cancelled = false;
102
141
  setCryptoContextReady(false);
103
142
  let mailboxSettled = false;
@@ -134,9 +173,26 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
134
173
  };
135
174
  }, [mailboxUid]);
136
175
  useEffect(() => {
176
+ if (!mailboxUid) {
177
+ return;
178
+ }
179
+ // Cancellable so a slow response for a mailbox the user has since switched away from can't point
180
+ // the new draft at the wrong mailbox's Drafts folder.
181
+ let cancelled = false;
137
182
  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."));
183
+ .then((folders) => {
184
+ if (!cancelled) {
185
+ setDraftsFolderUid(folders.find((f) => f.type === "drafts")?.uid);
186
+ }
187
+ })
188
+ .catch((err) => {
189
+ if (!cancelled) {
190
+ setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder.");
191
+ }
192
+ });
193
+ return () => {
194
+ cancelled = true;
195
+ };
140
196
  }, [mailboxUid]);
141
197
  /**
142
198
  * Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
@@ -154,7 +210,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
154
210
  const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
155
211
  const unchecked = addresses.filter((r) => !(r.address in recipientStatuses));
156
212
  for (const recipient of unchecked) {
157
- lookupKeys(mailboxUid, recipient.address)
213
+ lookupKeys(mailbox.uid, recipient.address)
158
214
  .catch(() => undefined)
159
215
  .then((lookup) => {
160
216
  const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, lookup);
@@ -171,6 +227,11 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
171
227
  // signature is a completely legitimate outcome, so this falls back to just the quoted content
172
228
  // (if any) rather than surfacing an error over what's a cosmetic nicety.
173
229
  useEffect(() => {
230
+ // Seeds the body once, from whichever mailbox is the sender when compose opens - switching From
231
+ // later must not overwrite what the user has already written (the editor itself never re-syncs).
232
+ if (!mailboxUid || contentReady) {
233
+ return;
234
+ }
174
235
  listMailSignatures(mailboxUid)
175
236
  .then((signatures) => {
176
237
  const signature = signatures.find((s) => signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward);
@@ -181,13 +242,48 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
181
242
  .finally(() => setContentReady(true));
182
243
  }, [mailboxUid, signatureContext, initialQuotedHtml]);
183
244
  useEffect(() => {
184
- if (!draftsFolderUid || draft) {
245
+ if (!mailboxUid || !draftsFolderUid || draft) {
185
246
  return;
186
247
  }
248
+ let cancelled = false;
187
249
  createDraft(mailboxUid, draftsFolderUid)
188
- .then(setDraft)
189
- .catch((err) => setDraftError(err instanceof ApiRequestError ? err.message : "Could not start a new draft."));
250
+ .then((created) => {
251
+ if (cancelled) {
252
+ // The sender changed while this was in flight - discard the now-orphaned draft.
253
+ void deleteMessage(created.uid, created.version).catch(() => undefined);
254
+ return;
255
+ }
256
+ setDraft(created);
257
+ })
258
+ .catch((err) => {
259
+ if (!cancelled) {
260
+ setDraftError(err instanceof ApiRequestError ? err.message : "Could not start a new draft.");
261
+ }
262
+ });
263
+ return () => {
264
+ cancelled = true;
265
+ };
190
266
  }, [mailboxUid, draftsFolderUid, draft]);
267
+ /** Switches the sending mailbox: discards the current (unsent, upload-free) draft and resets the
268
+ * per-mailbox state, so the effects above start a fresh draft and crypto context in the new mailbox.
269
+ * Recipients, subject, and body are kept. */
270
+ function handleFromChange(nextMailboxUid) {
271
+ if (nextMailboxUid === mailboxUid || hasUploads) {
272
+ return;
273
+ }
274
+ if (draft) {
275
+ void deleteMessage(draft.uid, draft.version).catch(() => undefined);
276
+ }
277
+ setDraft(null);
278
+ setDraftsFolderUid(undefined);
279
+ setDraftError(null);
280
+ setFolderError(null);
281
+ setMailbox(null);
282
+ setRecipientStatuses({});
283
+ setEncryptRequested(false);
284
+ setEncryptionBlocked(null);
285
+ setFromMailboxUid(nextMailboxUid);
286
+ }
191
287
  /**
192
288
  * Click-and-drag resize, from the window's own top/left edges (and the top-left corner, for both
193
289
  * at once) — the window is anchored bottom-right (see `ComposeContext.tsx`'s stack), so growing it
@@ -241,6 +337,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
241
337
  }
242
338
  try {
243
339
  const attachment = await uploadAttachment(draft.uid, file);
340
+ setHasUploads(true);
244
341
  return attachmentContentUrl(attachment.uid);
245
342
  }
246
343
  catch (err) {
@@ -260,6 +357,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
260
357
  for (const file of files) {
261
358
  try {
262
359
  const attachment = await uploadAttachment(draft.uid, file);
360
+ setHasUploads(true);
263
361
  setAttachments((prev) => [...prev, attachment]);
264
362
  }
265
363
  catch (err) {
@@ -285,6 +383,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
285
383
  * requirement, just without a live UI reflecting it before Send is clicked.
286
384
  */
287
385
  async function assembleForSend(toRecipients, ccRecipients, bccRecipients, forcePlaintext) {
386
+ // Only reachable once a draft exists, which itself requires `mailboxUid` to have resolved.
288
387
  const unlocked = getUnlockedKeys(mailboxUid);
289
388
  // `mailbox` is required to build protected headers (own From address) whenever signing or
290
389
  // encrypting - not just guarded on the encryption branch below, since a signed-only message
@@ -396,9 +495,30 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
396
495
  setSending(false);
397
496
  }
398
497
  }
498
+ async function handleUnlockForCrypto() {
499
+ try {
500
+ // Only reachable via the button below, which never renders unless needsUnlockForCrypto is
501
+ // true - which itself requires mailbox.keys to already contain a real enrolled key (see
502
+ // hasEnrolledSigningKey/hasEnrolledEncryptionKey), so it's never empty/undefined here either.
503
+ await requestUnlock(mailbox.uid, mailbox.keys);
504
+ setUnlockRefresh((n) => n + 1);
505
+ }
506
+ catch {
507
+ // User dismissed the unlock dialog - nothing to do, the toggles below simply stay hidden.
508
+ }
509
+ }
399
510
  // A plain read from keySession.ts's module-level session store, not React state - see that module's
400
511
  // own doc comment. Cheap enough to read fresh on every render rather than caching in state.
401
- const unlockedKeys = getUnlockedKeys(mailboxUid);
512
+ const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
513
+ const hasEnrolledSigningKey = !!findActivePublicKey(mailbox?.keys ?? [], "sign");
514
+ const hasEnrolledEncryptionKey = !!findActivePublicKey(mailbox?.keys ?? [], "encrypt");
515
+ // This mailbox has a real signing/encryption key on file, but this session hasn't unlocked it yet -
516
+ // the sign/encrypt toggles below stay hidden until it has, so this is the only way to reach them
517
+ // without first stumbling into Mail generally (which no longer force-prompts on its own - see
518
+ // `MailShell`'s `blocking={false}`). `getUnlockedKeys()` is re-read as a plain module value above, so
519
+ // bumping `unlockRefresh` after a successful unlock is what makes this line (and the toggles it
520
+ // gates) reflect it on the next render.
521
+ const needsUnlockForCrypto = (hasEnrolledSigningKey || hasEnrolledEncryptionKey) && !unlockedKeys;
402
522
  const title = subject.trim() || "New Message";
403
523
  const titleId = `compose-title-${id}`;
404
524
  if (minimized) {
@@ -412,7 +532,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
412
532
  ].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
533
  setEncryptionBlocked(null);
414
534
  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)) })] })] })), (() => {
535
+ }, children: "Send without encryption" })] }) })), mailboxes.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: mailboxes.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)) })] })] })), (() => {
416
536
  const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
417
537
  .map((r) => recipientStatuses[r.address])
418
538
  .filter((status) => !!status);
@@ -420,5 +540,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
420
540
  return null;
421
541
  }
422
542
  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 }) })] })] })] }));
543
+ })(), _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: () => 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 }) })] })] })] }));
424
544
  }
@@ -14,7 +14,17 @@ import AppShell from "../../layout/AppShell.js";
14
14
  import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
15
15
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
16
  import { useCompose } from "../compose/ComposeContext.js";
17
- const MailShellContext = createContext({ mailboxes: [], folders: [] });
17
+ import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
18
+ /** The well-known mail folder types an "aggregate" pseudo-folder can merge across every accessible
19
+ * mailbox - a fixed, small set: `outbox` is deliberately excluded (transient per-mailbox send-queue
20
+ * state, not a "merge across mailboxes" concept), and there's no aggregate concept for `calendar`/
21
+ * `contacts`/`tasks`/`notes`/`user` folders (Mail's own sidebar already excludes those - see
22
+ * `MAIL_FOLDER_TYPES`). */
23
+ export const AGGREGATE_FOLDER_TYPES = ["inbox", "sent_items", "drafts", "deleted_items", "junk"];
24
+ function isAggregateFolderType(value) {
25
+ return !!value && AGGREGATE_FOLDER_TYPES.includes(value);
26
+ }
27
+ const MailShellContext = createContext({ mailboxes: [], mailboxFolders: [] });
18
28
  /** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
19
29
  export function useMailShell() {
20
30
  return useContext(MailShellContext);
@@ -41,6 +51,12 @@ function folderSortKey(folder) {
41
51
  const idx = FOLDER_ORDER.indexOf(folder.type);
42
52
  return idx === -1 ? FOLDER_ORDER.length : idx;
43
53
  }
54
+ function sortedFoldersOf(folders) {
55
+ return [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name));
56
+ }
57
+ function aggregateUnreadCount(mailboxFolders, type) {
58
+ return mailboxFolders.reduce((total, mf) => total + (mf.folders.find((f) => f.type === type)?.unreadCount ?? 0), 0);
59
+ }
44
60
  /**
45
61
  * A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
46
62
  * `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
@@ -48,33 +64,45 @@ function folderSortKey(folder) {
48
64
  * own function body would see only whatever context exists *above* `MailShell`, never a provider one of
49
65
  * its own descendants creates. This button, rendered as part of `AppShell`'s `children`, sits correctly
50
66
  * inside that subtree.
67
+ *
68
+ * Opens with no mailbox: a fresh message defaults to the caller's own mailbox, and the compose window's
69
+ * own From field is where the sender is chosen.
51
70
  */
52
- function ComposeButton({ mailboxUid }) {
71
+ function ComposeButton() {
53
72
  const { openCompose } = useCompose();
54
- return (_jsx("button", { type: "button", onClick: () => openCompose({ mailboxUid }), className: "block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark", children: "Compose" }));
73
+ return (_jsx("button", { type: "button", onClick: () => openCompose({}), className: "block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark", children: "Compose" }));
55
74
  }
56
75
  /**
57
- * Mail's own contextual sidebar (mailbox switcher + folder tree) + content area, rendered inside the shared
58
- * `AppShell` chrome (icon rail, header, impersonation banner — see that component). There is no client-side
59
- * router in this framework (see `ReactRoute`'s file-convention resolver) — the selected mailbox/folder live
60
- * in the URL's `?mailboxUid=`/`?folderUid=` query params, read once on mount (never during the initial
61
- * render itself, matching every other query-param reader in this codebase — e.g. `apps/admin/quarantine`'s
62
- * `readMailboxUid()` — so the server-rendered and just-hydrated client markup match).
76
+ * Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
77
+ * aggregate section) + content area, rendered inside the shared `AppShell` chrome (icon rail, header,
78
+ * impersonation banner — see that component). There is no client-side router in this framework (see
79
+ * `ReactRoute`'s file-convention resolver) — the selected mailbox/folder (or aggregate pseudo-folder) live
80
+ * in the URL's `?mailboxUid=`/`?folderUid=`/`?aggregate=` query params, read once on mount (never during
81
+ * the initial render itself, matching every other query-param reader in this codebase — e.g.
82
+ * `apps/admin/quarantine`'s `readMailboxUid()` — so the server-rendered and just-hydrated client markup
83
+ * match).
84
+ *
85
+ * Every accessible mailbox's folder tree renders at once - there is no "switch mailbox" affordance
86
+ * anymore (the mailbox `<select>` this shell used to have is gone) - so a single-mailbox user sees zero
87
+ * behavior change from before this, and a multi-mailbox user sees every mailbox's folders (and the new
88
+ * aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
89
+ * of folders" requirement.
63
90
  */
64
91
  export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
65
92
  const [status, setStatus] = useState("checking");
66
93
  const [error, setError] = useState(null);
67
94
  const [mailboxes, setMailboxes] = useState([]);
68
- const [folders, setFolders] = useState([]);
95
+ const [mailboxFolders, setMailboxFolders] = useState([]);
69
96
  const [foldersLoading, setFoldersLoading] = useState(true);
70
- const [folderError, setFolderError] = useState(null);
71
97
  const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
72
98
  const [requestedFolderUid, setRequestedFolderUid] = useState(null);
99
+ const [requestedAggregateType, setRequestedAggregateType] = useState(null);
73
100
  const [drawerOpen, setDrawerOpen] = useState(false);
74
101
  useEffect(() => {
75
102
  const params = new URLSearchParams(window.location.search);
76
103
  setRequestedMailboxUid(params.get("mailboxUid"));
77
104
  setRequestedFolderUid(params.get("folderUid"));
105
+ setRequestedAggregateType(params.get("aggregate"));
78
106
  }, []);
79
107
  useEffect(() => {
80
108
  if (!userUid) {
@@ -90,27 +118,54 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
90
118
  setStatus("error");
91
119
  });
92
120
  }, [userUid]);
93
- const mailboxUid = (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
94
- mailboxes[0]?.uid;
121
+ const aggregateFolderType = isAggregateFolderType(requestedAggregateType)
122
+ ? requestedAggregateType
123
+ : undefined;
124
+ const mailboxUid = aggregateFolderType
125
+ ? undefined
126
+ : (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
127
+ mailboxes[0]?.uid;
128
+ // Fans out one listFolders() call per accessible mailbox in parallel - each call catches its own
129
+ // failure into an MailboxFolders.error rather than letting Promise.all reject, so one mailbox's fetch
130
+ // failure renders that section's own inline Alert instead of blanking out every other mailbox's
131
+ // folder tree.
95
132
  useEffect(() => {
96
- if (!mailboxUid) {
97
- setFolders([]);
133
+ if (mailboxes.length === 0) {
134
+ // Deliberately leaves foldersLoading as-is: this also runs once before listMailboxes() has
135
+ // resolved, and clearing it here would briefly render an empty sidebar the moment mailboxes
136
+ // arrive, before their folders do. A genuinely mailbox-less caller gets MailboxProvisioning.
137
+ setMailboxFolders([]);
98
138
  return;
99
139
  }
100
- setFolderError(null);
101
140
  setFoldersLoading(true);
102
- listFolders(mailboxUid)
103
- .then((result) => setFolders(result.filter((f) => MAIL_FOLDER_TYPES.has(f.type))))
104
- .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load folders."))
141
+ Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
142
+ .then((result) => ({ mailbox, folders: result.filter((f) => MAIL_FOLDER_TYPES.has(f.type)) }))
143
+ .catch((err) => ({
144
+ mailbox,
145
+ folders: [],
146
+ error: err instanceof ApiRequestError ? err.message : "Could not load folders.",
147
+ }))))
148
+ .then(setMailboxFolders)
105
149
  .finally(() => setFoldersLoading(false));
106
- }, [mailboxUid]);
107
- const folderUid = (requestedFolderUid && folders.some((f) => f.uid === requestedFolderUid) ? requestedFolderUid : undefined) ??
108
- folders.find((f) => f.type === "inbox")?.uid;
109
- const contextValue = useMemo(() => ({ mailboxUid, folderUid, mailboxes, folders }), [mailboxUid, folderUid, mailboxes, folders]);
110
- const sortedFolders = useMemo(() => [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name)), [folders]);
111
- // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
112
- // for a mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all.
113
- if (userUid && status === "ready" && !mailboxUid) {
150
+ }, [mailboxes]);
151
+ const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
152
+ const folderUid = aggregateFolderType
153
+ ? undefined
154
+ : (requestedFolderUid && selectedMailboxFolders.some((f) => f.uid === requestedFolderUid) ? requestedFolderUid : undefined) ??
155
+ selectedMailboxFolders.find((f) => f.type === "inbox")?.uid;
156
+ // The mailbox `KeyEnrollmentGate`/`LocalIndexLifecycle`/`ComposeButton` treat as "the" mailbox when
157
+ // there's no single selected one to use (aggregate mode) - the caller's own owned mailbox if they
158
+ // have one, else whichever accessible mailbox happens to be first. See MailShell's own doc comment on
159
+ // this being an accepted limitation: an aggregate-view message from a *different*, not-yet-visited
160
+ // mailbox may still need that mailbox's own folder view opened directly to unlock/decrypt it.
161
+ const defaultMailboxUid = mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
162
+ const activeMailboxUid = mailboxUid ?? defaultMailboxUid;
163
+ const contextValue = useMemo(() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders }), [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders]);
164
+ // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
165
+ // mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all. Checks
166
+ // `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
167
+ // aggregate mode even with mailboxes present.
168
+ if (userUid && status === "ready" && mailboxes.length === 0) {
114
169
  return _jsx(MailboxProvisioning, {});
115
170
  }
116
171
  let inner = null;
@@ -125,17 +180,22 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
125
180
  else if (userUid && status === "ready") {
126
181
  // A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
127
182
  // `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
128
- // unmounted), so the mailbox-switcher `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct
129
- // value per instance. Two elements sharing one id breaks label association (and is invalid HTML).
130
- const sidebarContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, { mailboxUid: mailboxUid }) }), mailboxes.length > 1 && (_jsxs("div", { className: "px-3 pb-2", children: [_jsx("label", { className: "block text-xs font-bold uppercase tracking-wide text-text-muted mb-1", htmlFor: `${idPrefix}-mailbox-switcher`, children: "Mailbox" }), _jsx("select", { id: `${idPrefix}-mailbox-switcher`, className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: mailboxUid, onChange: (e) => {
131
- window.location.href = `/?mailboxUid=${encodeURIComponent(e.target.value)}`;
132
- }, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), folderError && (_jsx("div", { className: "px-3 pb-2", children: _jsx(Alert, { children: folderError }) })), _jsx("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-0.5", children: foldersLoading ? (_jsx(SkeletonList, { count: 5, className: "pt-1" })) : (sortedFolders.map((folder) => (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailboxUid)}&folderUid=${encodeURIComponent(folder.uid)}`, className: [
133
- "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
134
- folder.uid === folderUid
135
- ? "bg-primary/10 text-primary-dark font-semibold"
136
- : "text-text hover:bg-surface-alt",
137
- ].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[folder.type] ?? folder.name }), folder.unreadCount > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: folder.unreadCount }))] }, folder.uid)))) })] }));
138
- inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
183
+ // unmounted).
184
+ const sidebarContent = () => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, {}) }), foldersLoading ? (_jsx("div", { className: "flex-1 overflow-y-auto px-3 pb-3", children: _jsx(SkeletonList, { count: 6, className: "pt-1" }) })) : (_jsxs("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3", children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5", children: "All Mailboxes" }), _jsx("div", { className: "flex flex-col gap-0.5", children: AGGREGATE_FOLDER_TYPES.map((type) => {
185
+ const unread = aggregateUnreadCount(mailboxFolders, type);
186
+ return (_jsxs("a", { href: `/?aggregate=${encodeURIComponent(type)}`, className: [
187
+ "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
188
+ aggregateFolderType === type
189
+ ? "bg-primary/10 text-primary-dark font-semibold"
190
+ : "text-text hover:bg-surface-alt",
191
+ ].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[type] }), unread > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: unread }))] }, type));
192
+ }) })] })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsxs("div", { children: [_jsxs("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate", children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }), mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: sortedFoldersOf(folders).map((folder) => (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`, className: [
193
+ "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
194
+ folder.uid === folderUid
195
+ ? "bg-primary/10 text-primary-dark font-semibold"
196
+ : "text-text hover:bg-surface-alt",
197
+ ].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[folder.type] ?? folder.name }), folder.unreadCount > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: folder.unreadCount }))] }, folder.uid))) })] }, mailbox.uid)))] }))] }));
198
+ inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent() }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent() }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
139
199
  }
140
200
  // Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
141
201
  // resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
@@ -143,6 +203,12 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
143
203
  // position) would make AppShell itself remount the moment mailboxUid resolves, tearing down
144
204
  // whatever state/effects it had already started (confirmed by direct reproduction: the
145
205
  // impersonation banner's own internal state was lost exactly at that transition).
146
- const selectedMailbox = mailboxes.find((mb) => mb.uid === mailboxUid);
147
- return (_jsx(KeyEnrollmentGate, { mailboxUid: mailboxUid, mailboxAddress: selectedMailbox?.primarySmtpAddress, mailboxKeys: selectedMailbox?.keys, children: _jsx(AppShell, { active: "mail", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, children: inner }) }));
206
+ const activeMailbox = mailboxes.find((mb) => mb.uid === activeMailboxUid);
207
+ return (_jsxs(KeyEnrollmentGate, { mailboxUid: activeMailboxUid, mailboxAddress: activeMailbox?.primarySmtpAddress, mailboxKeys: activeMailbox?.keys,
208
+ // Unlocking is only actually required to sign/encrypt a compose, read an already-encrypted
209
+ // message, or change encryption settings - not merely to open Mail. Those specific call sites
210
+ // (ComposeWindow, MessageDetailPane) request an unlock on demand via useUnlockPrompt() instead.
211
+ // First-time provisioning (a mailbox with no vault at all yet) still always blocks - see this
212
+ // prop's own doc comment on KeyEnrollmentGateProps.
213
+ blocking: false, children: [_jsx(LocalIndexLifecycle, { mailboxUid: activeMailboxUid, mailboxKeys: activeMailbox?.keys, folders: mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? [] }), _jsx(AppShell, { active: "mail", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, children: inner })] }));
148
214
  }
@@ -22,6 +22,7 @@ export const SETTINGS_SECTIONS = [
22
22
  { id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
23
23
  { id: "booking-types", href: "/settings/booking-types", label: "Booking Links" },
24
24
  { id: "encryption", href: "/settings/encryption", label: "Encryption" },
25
+ { id: "sharing", href: "/settings/sharing", label: "Sharing" },
25
26
  { id: "privacy", href: "/settings/privacy", label: "Privacy & Data" },
26
27
  ];
27
28
  const SettingsShellContext = createContext({ mailboxes: [] });