@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.
- package/apps/admin/branding/index.tsx +39 -404
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/index.tsx +100 -82
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
- package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +40 -4
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactForm.tsx +38 -4
- package/apps/shared/components/layout/AppShell.tsx +198 -169
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
- package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
- package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +179 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +116 -0
- package/apps/shared/search/localIndexSchema.ts +227 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexVFS.ts +235 -0
- package/apps/shared/search/localIndexWorker.ts +464 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +31 -21
- package/apps/www/contacts/index.tsx +33 -6
- package/apps/www/index.tsx +661 -106
- package/apps/www/messages/[uid].tsx +5 -1
- package/apps/www/settings/encryption/index.tsx +63 -1
- package/apps/www/settings/sharing/index.tsx +271 -0
- package/apps/www/tasks/index.tsx +53 -4
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/index.js +14 -1
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +16 -4
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
- package/dist/apps/shared/components/layout/AppShell.js +30 -7
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
- package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +153 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
- package/dist/apps/shared/search/localIndexSchema.js +179 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexVFS.js +230 -0
- package/dist/apps/shared/search/localIndexWorker.js +341 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +14 -12
- package/dist/apps/www/contacts/index.js +15 -6
- package/dist/apps/www/index.js +496 -94
- package/dist/apps/www/messages/[uid].js +5 -1
- package/dist/apps/www/settings/encryption/index.js +30 -2
- package/dist/apps/www/settings/sharing/index.js +128 -0
- package/dist/apps/www/tasks/index.js +27 -5
- 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 && (
|
|
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,
|
|
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) =>
|
|
139
|
-
|
|
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(
|
|
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(
|
|
189
|
-
|
|
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
|
-
|
|
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(
|
|
71
|
+
function ComposeButton() {
|
|
53
72
|
const { openCompose } = useCompose();
|
|
54
|
-
return (_jsx("button", { type: "button", onClick: () => openCompose({
|
|
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
|
|
58
|
-
* `AppShell` chrome (icon rail, header,
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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 [
|
|
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
|
|
94
|
-
|
|
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 (
|
|
97
|
-
|
|
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(
|
|
103
|
-
.then((result) =>
|
|
104
|
-
.catch((err) =>
|
|
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
|
-
}, [
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
//
|
|
113
|
-
|
|
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)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
147
|
-
return (
|
|
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: [] });
|