@rapidmx/web-client 0.35.0 → 0.36.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/escrow/matters/[uid].tsx +4 -3
- package/apps/shared/calendar/sealedEvents.ts +80 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
- package/apps/shared/components/contacts/contactCardData.ts +2 -1
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +23 -1
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +91 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
- package/apps/shared/components/mail/ConversationList.tsx +1 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +35 -13
- package/apps/shared/components/mail/compose/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
- package/apps/shared/components/mail/invite/inviteStore.ts +7 -1
- package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
- package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
- package/apps/shared/components/settings/KeyExportSetting.tsx +170 -0
- package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +117 -0
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +2 -2
- package/apps/shared/mail/reportNotices.ts +3 -1
- package/apps/shared/mail/zeroAccessSource.ts +27 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +31 -0
- package/apps/shared/search/localIndexBuilder.ts +27 -1
- package/apps/shared/search/localIndexRpcClient.ts +8 -4
- package/apps/shared/search/localIndexSchema.ts +2 -2
- package/apps/shared/search/localIndexTransport.ts +2 -1
- package/apps/shared/search/localIndexWorker.ts +6 -4
- package/apps/shared/search/zeroAccessIndexSync.ts +487 -0
- package/apps/www/calendar/index.tsx +176 -56
- package/apps/www/index.tsx +10 -3
- package/apps/www/settings/encryption/index.tsx +5 -0
- package/apps/www/settings/privacy/index.tsx +2 -1
- package/dist/apps/escrow/matters/[uid].js +2 -2
- package/dist/apps/shared/calendar/sealedEvents.d.ts +13 -0
- package/dist/apps/shared/calendar/sealedEvents.js +54 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +18 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +2 -1
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +21 -2
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +15 -0
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +72 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +34 -13
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
- package/dist/apps/shared/components/mail/invite/inviteStore.js +6 -1
- package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
- package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/KeyExportSetting.js +88 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +14 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +71 -0
- package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
- package/dist/apps/shared/mail/decryptedMessages.js +11 -3
- package/dist/apps/shared/mail/outbox/sendJob.js +2 -2
- package/dist/apps/shared/mail/reportNotices.js +3 -1
- package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
- package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +28 -0
- package/dist/apps/shared/search/localIndexBuilder.d.ts +5 -0
- package/dist/apps/shared/search/localIndexBuilder.js +24 -2
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +1 -1
- package/dist/apps/shared/search/localIndexRpcClient.js +6 -4
- package/dist/apps/shared/search/localIndexSchema.d.ts +1 -1
- package/dist/apps/shared/search/localIndexSchema.js +2 -2
- package/dist/apps/shared/search/localIndexTransport.d.ts +2 -1
- package/dist/apps/shared/search/localIndexWorker.d.ts +2 -0
- package/dist/apps/shared/search/localIndexWorker.js +4 -4
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +71 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.js +382 -0
- package/dist/apps/www/calendar/index.js +41 -15
- package/dist/apps/www/index.js +10 -3
- package/dist/apps/www/settings/encryption/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +1 -1
- package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
- package/dist/lib/calendar/calendarApi.d.ts +6 -0
- package/dist/lib/calendar/inviteApi.js +27 -5
- package/dist/lib/calendar/sealedInvites.d.ts +11 -0
- package/dist/lib/calendar/sealedInvites.js +28 -0
- package/dist/lib/crypto/keyExport.d.ts +54 -0
- package/dist/lib/crypto/keyExport.js +161 -0
- package/dist/lib/crypto/keySession.d.ts +45 -1
- package/dist/lib/crypto/keySession.js +93 -2
- package/dist/lib/crypto/keyvaultApi.d.ts +88 -3
- package/dist/lib/crypto/keyvaultApi.js +32 -0
- package/dist/lib/crypto/messageSecurity.d.ts +30 -1
- package/dist/lib/crypto/messageSecurity.js +80 -4
- package/dist/lib/crypto/mime.d.ts +5 -0
- package/dist/lib/crypto/mime.js +25 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +50 -0
- package/dist/lib/crypto/sharedMailboxKeys.js +264 -0
- package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
- package/dist/lib/crypto/zeroAccessKey.js +66 -0
- package/dist/lib/mail/conversationsApi.d.ts +6 -0
- package/dist/lib/mail/mailApi.d.ts +30 -2
- package/dist/lib/mail/mailApi.js +5 -1
- package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -0
- package/lib/admin/escrowAccessRequestsApi.ts +6 -0
- package/lib/calendar/calendarApi.ts +3 -0
- package/lib/calendar/inviteApi.ts +28 -5
- package/lib/calendar/sealedInvites.ts +33 -0
- package/lib/crypto/keyExport.ts +229 -0
- package/lib/crypto/keySession.ts +117 -3
- package/lib/crypto/keyvaultApi.ts +125 -3
- package/lib/crypto/messageSecurity.ts +110 -3
- package/lib/crypto/mime.ts +26 -0
- package/lib/crypto/sharedMailboxKeys.ts +324 -0
- package/lib/crypto/zeroAccessKey.ts +97 -0
- package/lib/mail/conversationsApi.ts +3 -0
- package/lib/mail/mailApi.ts +33 -3
- package/lib/search/zeroAccessIndexApi.ts +119 -0
- package/package.json +3 -3
|
@@ -36,10 +36,14 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
|
|
|
36
36
|
const [saved, setSaved] = useState(false);
|
|
37
37
|
// Edits are unsaved until they match what was loaded or last saved.
|
|
38
38
|
const [savedValues, setSavedValues] = useState(policy);
|
|
39
|
-
|
|
39
|
+
// A server without zero-access encryption sends no `allowZeroAccess`; the checkbox is then not shown at all.
|
|
40
|
+
const zeroAccessSupported = policy.allowZeroAccess !== undefined;
|
|
41
|
+
const dirty = TIERS.some((tier) => values[tier.key] !== savedValues[tier.key]) || (zeroAccessSupported && values.allowZeroAccess !== savedValues.allowZeroAccess);
|
|
40
42
|
useEffect(() => onDirtyChange?.(dirty), [dirty]);
|
|
41
43
|
// Tiers this save would newly set to "Never encrypt", which switches encryption off for everyone's mail of that kind: confirmed first.
|
|
42
44
|
const [confirmingProhibit, setConfirmingProhibit] = useState(null);
|
|
45
|
+
// Turning zero-access encryption off makes new mail readable by this server again for every mailbox: confirmed first.
|
|
46
|
+
const [confirmingZeroAccessOff, setConfirmingZeroAccessOff] = useState(false);
|
|
43
47
|
function handleSubmit(e) {
|
|
44
48
|
e.preventDefault();
|
|
45
49
|
const newlyProhibited = TIERS.filter((tier) => values[tier.key] === "prohibited" && savedValues[tier.key] !== "prohibited");
|
|
@@ -47,10 +51,19 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
|
|
|
47
51
|
setConfirmingProhibit(newlyProhibited.map((tier) => tier.label));
|
|
48
52
|
return;
|
|
49
53
|
}
|
|
54
|
+
confirmZeroAccessThenSave();
|
|
55
|
+
}
|
|
56
|
+
function confirmZeroAccessThenSave() {
|
|
57
|
+
setConfirmingProhibit(null);
|
|
58
|
+
if (zeroAccessSupported && values.allowZeroAccess === false && savedValues.allowZeroAccess !== false) {
|
|
59
|
+
setConfirmingZeroAccessOff(true);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
50
62
|
void save();
|
|
51
63
|
}
|
|
52
64
|
async function save() {
|
|
53
65
|
setConfirmingProhibit(null);
|
|
66
|
+
setConfirmingZeroAccessOff(false);
|
|
54
67
|
setError(null);
|
|
55
68
|
setSaved(false);
|
|
56
69
|
setSaving(true);
|
|
@@ -70,5 +83,8 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
|
|
|
70
83
|
return (_jsxs("div", { className: "max-w-xl", children: [embedded ? (_jsx("h3", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "End-to-end encryption" })) : (_jsx("h2", { className: "text-lg font-bold uppercase tracking-wide mb-1", children: "End-to-end encryption" })), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Encrypted messages can only be read by their senders and recipients - not by this server or its administrators. Choose how encryption applies to each kind of mail." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [TIERS.map((tier) => (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: tier.label }), _jsx("select", { "aria-label": tier.label, className: SELECT_CLASS, value: values[tier.key], onChange: (e) => {
|
|
71
84
|
setSaved(false);
|
|
72
85
|
setValues({ ...values, [tier.key]: e.target.value });
|
|
73
|
-
}, children: STATES.map((state) => (_jsx("option", { value: state.value, children: state.label }, state.value))) }), _jsx("span", { className: "text-xs text-text-muted", children: tier.help })] }, tier.key))),
|
|
86
|
+
}, children: STATES.map((state) => (_jsx("option", { value: state.value, children: state.label }, state.value))) }), _jsx("span", { className: "text-xs text-text-muted", children: tier.help })] }, tier.key))), zeroAccessSupported && (_jsxs("div", { className: "mt-2 pt-4 border-t border-border", children: [_jsx("span", { className: "font-semibold text-sm block mb-1", children: "Zero-access encryption" }), _jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: values.allowZeroAccess !== false, onChange: (e) => {
|
|
87
|
+
setSaved(false);
|
|
88
|
+
setValues({ ...values, allowZeroAccess: e.target.checked });
|
|
89
|
+
} }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: "Allow zero-access encryption" }), _jsx("span", { className: "text-xs text-text-muted", children: "Mail that arrives unencrypted is encrypted the moment it is received, to a key only the mailbox\u2019s owner can unlock, and this server keeps no readable copy - administrators can\u2019t read it either, except through an escrow scope. People can still turn it off for their own mailbox. Search of message contents then happens in the app instead of on the server." })] })] })] })), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] }), _jsxs(Modal, { open: confirmingProhibit !== null, onClose: () => setConfirmingProhibit(null), title: "Turn off encryption?", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Saving sets ", _jsx("strong", { children: confirmingProhibit?.join(", ") }), " to Never encrypt. Nobody on this server can then encrypt that mail end to end, whatever they have chosen for themselves, until an administrator allows it again."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingProhibit(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: confirmZeroAccessThenSave, children: "Save and turn off encryption" })] })] }), _jsxs(Modal, { open: confirmingZeroAccessOff, onClose: () => setConfirmingZeroAccessOff(false), title: "Turn off zero-access encryption?", children: [_jsx("p", { className: "text-sm mb-3", children: "Mail that arrives from now on will be stored in a form this server can read, for every mailbox. Mail that is already encrypted stays encrypted - this server can\u2019t decrypt it. Everyone sees in their settings that an administrator turned it off." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingZeroAccessOff(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: () => void save(), children: "Save and turn off zero-access encryption" })] })] })] }));
|
|
74
90
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { addDays } from "date-fns";
|
|
11
11
|
import { listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
|
|
12
|
+
import { openSealedEvents } from "../../calendar/sealedEvents.js";
|
|
12
13
|
import { expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
|
|
13
14
|
import { createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
|
|
14
15
|
import { parseVCards } from "../../../../lib/contacts/vcard.js";
|
|
@@ -53,7 +54,7 @@ export async function loadRecentMessages(mailboxes, address, client) {
|
|
|
53
54
|
export async function loadUpcomingEvents(folders, address, now, client) {
|
|
54
55
|
const wanted = normalizeAddress(address);
|
|
55
56
|
const calendars = folders.filter((folder) => folder.type === "calendar");
|
|
56
|
-
const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client)));
|
|
57
|
+
const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client).then(openSealedEvents)));
|
|
57
58
|
const involved = lists
|
|
58
59
|
.flat()
|
|
59
60
|
.filter((event) => event.status !== "cancelled" &&
|
|
@@ -12,7 +12,8 @@ import { enrollKey, getKeyVault, VaultAlreadyInitializedError } from "../../../.
|
|
|
12
12
|
import { UnopenableKeysNotice, unlockErrorMessage } from "./UnlockPromptProvider.js";
|
|
13
13
|
import { FrameTakeover } from "../../navigation/frameContext.js";
|
|
14
14
|
import { MIN_PASSWORD_LENGTH, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
|
|
15
|
-
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
|
|
15
|
+
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, subscribeKeySession, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
|
|
16
|
+
import { ensureZeroAccessKey, provisionZeroAccessKey } from "../../../../lib/crypto/zeroAccessKey.js";
|
|
16
17
|
import { buildAad, generateMasterKey, sealWithKey } from "../../../../lib/crypto/masterKey.js";
|
|
17
18
|
import { buildPasswordWrap, buildRecoveryWraps } from "../../../../lib/crypto/masterKeyWraps.js";
|
|
18
19
|
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "../../../../lib/crypto/keys.js";
|
|
@@ -27,12 +28,15 @@ async function provisionEncryptionKey(mailboxUid, mailboxAddress, password) {
|
|
|
27
28
|
const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
|
|
28
29
|
const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
|
|
29
30
|
const wrappedKeySealed = await sealWithKey(mk, privateKeyRaw, buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE));
|
|
30
|
-
await enrollKey(mailboxUid, {
|
|
31
|
+
const vault = await enrollKey(mailboxUid, {
|
|
31
32
|
useType: "encrypt",
|
|
32
33
|
csr: csrPem,
|
|
33
34
|
wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
|
|
34
35
|
masterKeyWraps: [passwordWrap, ...recoveryWraps],
|
|
35
36
|
});
|
|
37
|
+
// The zero-access key (restapi specs/search.md §3), under the same master key. Not fatal: setup is done once the
|
|
38
|
+
// encryption key is in, and a mailbox without a zero-access key gets one on its next unlock (`ensureZeroAccessKey()`).
|
|
39
|
+
await provisionZeroAccessKey(mailboxUid, mailboxAddress, mk, vault.masterKeyGeneration).catch(() => undefined);
|
|
36
40
|
return { recoveryCodes };
|
|
37
41
|
}
|
|
38
42
|
/**
|
|
@@ -90,6 +94,21 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
|
|
|
90
94
|
cancelled = true;
|
|
91
95
|
};
|
|
92
96
|
}, [mailboxUid, canProvision]);
|
|
97
|
+
// A mailbox set up before zero-access encryption existed has no zero-access key: give it one whenever its owner has
|
|
98
|
+
// it unlocked - now, or on a later unlock from anywhere (this gate, an unlock prompt, recovery). Best effort: a
|
|
99
|
+
// failure is retried on the next unlock.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!mailboxUid || !mailboxAddress || !canProvision) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const ensure = () => void ensureZeroAccessKey(mailboxUid, mailboxAddress).catch(() => undefined);
|
|
105
|
+
ensure();
|
|
106
|
+
return subscribeKeySession((event) => {
|
|
107
|
+
if (event.mailboxUid === mailboxUid && event.state === "unlocked") {
|
|
108
|
+
ensure();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}, [mailboxUid, mailboxAddress, canProvision]);
|
|
93
112
|
async function handleUnlock(e) {
|
|
94
113
|
e.preventDefault();
|
|
95
114
|
setError(null);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
2
|
+
/** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
|
|
3
|
+
export declare const SHARED_MAILBOX_CHECK_INTERVAL_MS: number;
|
|
4
|
+
export interface SharedMailboxKeyManagerProps {
|
|
5
|
+
/** Every mailbox the user can open. */
|
|
6
|
+
mailboxes: Mailbox[];
|
|
7
|
+
userUid?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
|
|
11
|
+
* unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
|
|
12
|
+
* password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
|
|
13
|
+
* silent: a pass that fails is tried again on the next. Renders nothing; mounted by `MailShell`.
|
|
14
|
+
*/
|
|
15
|
+
export default function SharedMailboxKeyManager({ mailboxes, userUid }: SharedMailboxKeyManagerProps): null;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
import { getUnlockedKeys, personallyUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
|
|
7
|
+
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
8
|
+
/** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
|
|
9
|
+
export const SHARED_MAILBOX_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
10
|
+
/** The shared mailbox key code (pkijs and friends), loaded only once an own mailbox is unlocked. */
|
|
11
|
+
let loaded;
|
|
12
|
+
const loadSharedKeys = () => (loaded ?? (loaded = import("../../../../lib/crypto/sharedMailboxKeys.js").catch((err) => {
|
|
13
|
+
loaded = undefined;
|
|
14
|
+
throw err;
|
|
15
|
+
})));
|
|
16
|
+
/**
|
|
17
|
+
* Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
|
|
18
|
+
* unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
|
|
19
|
+
* password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
|
|
20
|
+
* silent: a pass that fails is tried again on the next. Renders nothing; mounted by `MailShell`.
|
|
21
|
+
*/
|
|
22
|
+
export default function SharedMailboxKeyManager({ mailboxes, userUid }) {
|
|
23
|
+
const client = useApiClient();
|
|
24
|
+
const latest = useRef({ mailboxes, userUid, client });
|
|
25
|
+
latest.current = { mailboxes, userUid, client };
|
|
26
|
+
const running = useRef(false);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
async function pass() {
|
|
29
|
+
if (running.current || personallyUnlockedKeys().length === 0) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
running.current = true;
|
|
33
|
+
try {
|
|
34
|
+
const { maintainSharedMailbox, unlockSharedMailbox } = await loadSharedKeys();
|
|
35
|
+
const { mailboxes: current, userUid: me, client: apiClient } = latest.current;
|
|
36
|
+
for (const mailbox of current) {
|
|
37
|
+
await look(mailbox, me, apiClient, unlockSharedMailbox, maintainSharedMailbox).catch(() => undefined);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Nothing to do until the next pass.
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
running.current = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
void pass();
|
|
48
|
+
const unsubscribe = subscribeKeySession((event) => {
|
|
49
|
+
if (event.state === "unlocked" && !getUnlockedKeys(event.mailboxUid)?.shared) {
|
|
50
|
+
void pass();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const interval = setInterval(() => void pass(), SHARED_MAILBOX_CHECK_INTERVAL_MS);
|
|
54
|
+
return () => {
|
|
55
|
+
unsubscribe();
|
|
56
|
+
clearInterval(interval);
|
|
57
|
+
};
|
|
58
|
+
}, []);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
async function look(mailbox, me, client, unlockSharedMailbox, maintainSharedMailbox) {
|
|
62
|
+
const owned = !!mailbox.ownerUserUid && mailbox.ownerUserUid === me;
|
|
63
|
+
if (!owned && !getUnlockedKeys(mailbox.uid)) {
|
|
64
|
+
await unlockSharedMailbox(mailbox.uid, client);
|
|
65
|
+
}
|
|
66
|
+
const unlocked = getUnlockedKeys(mailbox.uid);
|
|
67
|
+
// An owner looks after their mailbox with its own keys; a member, with full access, whether or not it is open yet (a mailbox
|
|
68
|
+
// without an owner may have no key to open).
|
|
69
|
+
if (owned ? unlocked && !unlocked.shared : true) {
|
|
70
|
+
await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -36,6 +36,9 @@ interface UnlockPromptContextValue {
|
|
|
36
36
|
* mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
|
|
37
37
|
* `false` for exactly this reason.
|
|
38
38
|
*/
|
|
39
|
+
/** Messages for a shared mailbox `openShared()` can't open. */
|
|
40
|
+
export declare const SHARED_ACCESS_PENDING_MESSAGE = "This mailbox is shared with you, and is waiting for another member to share its key with you. Try again shortly.";
|
|
41
|
+
export declare const SHARED_OWN_KEYS_LOCKED_MESSAGE = "Unlock your own mailbox first: it holds the key that opens the mailboxes shared with you.";
|
|
39
42
|
export declare function UnlockPromptProvider({ children }: {
|
|
40
43
|
children: React.ReactNode;
|
|
41
44
|
}): React.JSX.Element;
|
|
@@ -9,6 +9,7 @@ import Modal from "../../../../lib/components/overlays/Modal.js";
|
|
|
9
9
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
10
10
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
11
11
|
import FormField from "../../../../lib/components/forms/FormField.js";
|
|
12
|
+
import { notify } from "../../notifications/store.js";
|
|
12
13
|
import { RECOVERY_CODE_ERROR, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
|
|
13
14
|
/** Shown instead of "Incorrect password" when the password was right but the mailbox's encryption key itself
|
|
14
15
|
* won't open (`UnopenableEncryptionKeyError`) - retrying can't fix that, so it must not read as a typo. */
|
|
@@ -44,6 +45,33 @@ const UnlockPromptContext = createContext(null);
|
|
|
44
45
|
* mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
|
|
45
46
|
* `false` for exactly this reason.
|
|
46
47
|
*/
|
|
48
|
+
/** Messages for a shared mailbox `openShared()` can't open. */
|
|
49
|
+
export const SHARED_ACCESS_PENDING_MESSAGE = "This mailbox is shared with you, and is waiting for another member to share its key with you. Try again shortly.";
|
|
50
|
+
export const SHARED_OWN_KEYS_LOCKED_MESSAGE = "Unlock your own mailbox first: it holds the key that opens the mailboxes shared with you.";
|
|
51
|
+
/**
|
|
52
|
+
* Opens `mailboxUid` through the user's member key wraps when it is a mailbox shared with them (`sharedMailboxKeys.ts`): its keys, or
|
|
53
|
+
* `undefined` when it isn't one of theirs (the password prompt applies). Rejects, after saying why, when it is shared with them but can't
|
|
54
|
+
* open yet. The shared key code is loaded only here.
|
|
55
|
+
*/
|
|
56
|
+
async function openShared(mailboxUid) {
|
|
57
|
+
let outcome;
|
|
58
|
+
try {
|
|
59
|
+
const { unlockSharedMailbox } = await import("../../../../lib/crypto/sharedMailboxKeys.js");
|
|
60
|
+
outcome = await unlockSharedMailbox(mailboxUid);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (outcome === "unlocked") {
|
|
66
|
+
return getUnlockedKeys(mailboxUid);
|
|
67
|
+
}
|
|
68
|
+
if (outcome === "pending" || outcome === "own_keys_locked") {
|
|
69
|
+
const message = outcome === "pending" ? SHARED_ACCESS_PENDING_MESSAGE : SHARED_OWN_KEYS_LOCKED_MESSAGE;
|
|
70
|
+
notify({ kind: "info", title: "Shared mailbox", message });
|
|
71
|
+
throw new Error(message);
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
47
75
|
export function UnlockPromptProvider({ children }) {
|
|
48
76
|
const [pending, setPendingState] = useState(null);
|
|
49
77
|
// Mirrors `pending` synchronously, so two requests issued in the same tick see each other.
|
|
@@ -66,6 +94,10 @@ export function UnlockPromptProvider({ children }) {
|
|
|
66
94
|
if (existing) {
|
|
67
95
|
return Promise.resolve(existing);
|
|
68
96
|
}
|
|
97
|
+
// A mailbox shared with the user opens through their member key wraps, not a password - and its password, if it has one, isn't theirs.
|
|
98
|
+
return openShared(mailboxUid).then((shared) => shared ?? promptForPassword(mailboxUid, mailboxKeys));
|
|
99
|
+
}, []);
|
|
100
|
+
const promptForPassword = useCallback((mailboxUid, mailboxKeys) => {
|
|
69
101
|
return new Promise((resolve, reject) => {
|
|
70
102
|
const current = pendingRef.current;
|
|
71
103
|
if (current?.mailboxUid === mailboxUid) {
|
|
@@ -49,7 +49,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
|
|
|
49
49
|
let stale = false;
|
|
50
50
|
void mapWithConcurrency(conversations.filter(conversationLooksEncrypted), 4, async (conversation) => {
|
|
51
51
|
if (!stale) {
|
|
52
|
-
await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
|
|
52
|
+
await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid, conversation.latestZeroAccess?.sealedSummary);
|
|
53
53
|
}
|
|
54
54
|
});
|
|
55
55
|
return () => {
|
|
@@ -12,6 +12,7 @@ import MoveToFolderDialog from "./MoveToFolderDialog.js";
|
|
|
12
12
|
import InviteCard from "./InviteCard.js";
|
|
13
13
|
import { isCalendarAttachment } from "./invite/inviteFormat.js";
|
|
14
14
|
import { useMessageInvite } from "./invite/inviteStore.js";
|
|
15
|
+
import { rememberSealedInvite } from "../../../../lib/calendar/sealedInvites.js";
|
|
15
16
|
import { getUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
|
|
16
17
|
import { extractAddresses } from "../../../../lib/crypto/mime.js";
|
|
17
18
|
import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "../../../../lib/crypto/keyvaultApi.js";
|
|
@@ -78,6 +79,9 @@ const SECURITY_INDICATOR = {
|
|
|
78
79
|
// Verified when first opened (a verification seal), but not live: muted, never the green verified pill. A signer key
|
|
79
80
|
// later reported compromised switches it to `LATER_COMPROMISED_INDICATOR_CLASS`.
|
|
80
81
|
verified_at_first_open: { label: "Verified when first opened", className: "bg-surface-alt text-text-muted" },
|
|
82
|
+
// Sealed by the server on arrival (zero-access): distinct from "Encrypted" - the server held this message in the clear while it
|
|
83
|
+
// delivered it - and never worded as end-to-end, per the spec's indicator table.
|
|
84
|
+
protected_at_rest: { label: "Protected at rest", className: "bg-primary/5 text-text" },
|
|
81
85
|
};
|
|
82
86
|
/** The amber `verified_at_first_open` badge for a signer key since reported compromised. */
|
|
83
87
|
const LATER_COMPROMISED_INDICATOR_CLASS = "bg-warning/15 text-text";
|
|
@@ -189,11 +193,14 @@ function IconAction({ icon, label, onClick, disabled, reason, busy, onPrefetch,
|
|
|
189
193
|
const env = useKeyEnvironment();
|
|
190
194
|
return (_jsx("button", { type: "button", "aria-label": label, title: reason ?? (shortcut ? withHint(label, shortcut, env) : label), "aria-keyshortcuts": shortcut ? ariaKeyShortcuts(shortcut, env) : undefined, "aria-busy": busy || undefined, disabled: disabled, onClick: onClick, onPointerEnter: onPrefetch, onFocus: onPrefetch, className: ICON_BUTTON_CLASS, children: icon }));
|
|
191
195
|
}
|
|
192
|
-
function SecurityIndicator({ security }) {
|
|
193
|
-
|
|
196
|
+
function SecurityIndicator({ security, zeroAccess }) {
|
|
197
|
+
// A zero-access message this device can't open yet (keys locked) evaluates as an encrypted one it has no key for: it is still
|
|
198
|
+
// labelled for what it is.
|
|
199
|
+
const state = zeroAccess && security.state === "encrypted" && security.decryptError !== undefined ? "protected_at_rest" : security.state;
|
|
200
|
+
const { label, className } = SECURITY_INDICATOR[state];
|
|
194
201
|
if (security.state !== "verified_at_first_open") {
|
|
195
202
|
// Every encrypted state carries a small lock, so the badge reads at a glance; the words are unchanged.
|
|
196
|
-
const encrypted =
|
|
203
|
+
const encrypted = state.startsWith("encrypted") || state === "protected_at_rest";
|
|
197
204
|
return (_jsxs("span", { className: `inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`, children: [encrypted && _jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), label] }));
|
|
198
205
|
}
|
|
199
206
|
return (_jsxs("span", { className: `inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${security.laterCompromised ? LATER_COMPROMISED_INDICATOR_CLASS : className}`, children: [security.laterCompromised ? (_jsx(HiOutlineExclamationTriangle, { size: 12, "aria-hidden": "true" })) : (_jsx(HiOutlineCheck, { size: 12, "aria-hidden": "true" })), label] }));
|
|
@@ -256,7 +263,7 @@ export default function MessageDetailPane(props) {
|
|
|
256
263
|
* surfaced as an attachment - so a message with neither flag can't carry a signature to verify, and
|
|
257
264
|
* fetching its full raw source (attachments included) would be pure waste. */
|
|
258
265
|
function needsRawSecurityEvaluation(message) {
|
|
259
|
-
return !!message.encrypted || message.hasAttachments;
|
|
266
|
+
return !!message.encrypted || !!message.zeroAccess || message.hasAttachments;
|
|
260
267
|
}
|
|
261
268
|
function MessageDetailContent({ message: messageProp, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, folders, onMoved, onFolderCreated, onReceiptHandled, onArchived, onChanged, labels, onLabelsChanged, onLabelCreated, inThread, threadSubject, threadHeader, footer, shortcuts, }) {
|
|
262
269
|
// A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
|
|
@@ -437,13 +444,13 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
437
444
|
})
|
|
438
445
|
.catch(() => {
|
|
439
446
|
if (!cancelled) {
|
|
440
|
-
setSecurity(message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" });
|
|
447
|
+
setSecurity(message.encrypted || message.zeroAccess ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" });
|
|
441
448
|
}
|
|
442
449
|
});
|
|
443
450
|
return () => {
|
|
444
451
|
cancelled = true;
|
|
445
452
|
};
|
|
446
|
-
}, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
|
|
453
|
+
}, [message.uid, message.mailboxUid, message.from.address, message.encrypted, message.zeroAccess?.keyFingerprint, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
|
|
447
454
|
// Whether the body is the locked state right now (an encrypted message this device holds no unlocked keys for), for the subscription below.
|
|
448
455
|
const lockedRef = useRef(false);
|
|
449
456
|
lockedRef.current = security?.decryptError !== undefined && !getUnlockedKeys(message.mailboxUid);
|
|
@@ -813,7 +820,11 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
813
820
|
// A decrypted message whose signature failed still only has these (the server list is just `smime.p7m`); they're
|
|
814
821
|
// listed with a warning. A signed-only failure never carries recovered attachments.
|
|
815
822
|
const innerAttachments = security?.attachments !== undefined &&
|
|
816
|
-
(verifiedOrSealed ||
|
|
823
|
+
(verifiedOrSealed ||
|
|
824
|
+
security.zeroAccess ||
|
|
825
|
+
security.state === "encrypted" ||
|
|
826
|
+
security.state === "encrypted_unverified_signer" ||
|
|
827
|
+
security.state === "signature_failed")
|
|
817
828
|
? security.attachments
|
|
818
829
|
: undefined;
|
|
819
830
|
// Any state with a valid signature shows the address actually signed for (the protected From when the message
|
|
@@ -863,7 +874,17 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
863
874
|
// Only a message the server can read and that carries a calendar file is worth asking about an invitation. An encrypted message is out: the
|
|
864
875
|
// server holds only its ciphertext (the calendar file is inside it), so there is nothing for it to find. Drafts and Outbox hold messages
|
|
865
876
|
// that are being written or sent, not received invitations.
|
|
866
|
-
|
|
877
|
+
// A zero-access message's calendar file is inside the sealed copy: the server reads the invitation from the one decrypted here.
|
|
878
|
+
const sealedCalendar = security?.zeroAccess ? security.calendar : undefined;
|
|
879
|
+
// Registered while rendering, not in an effect: the invitation lookup below must find it the moment it is enabled. Writing the same
|
|
880
|
+
// file for the same message again is a no-op.
|
|
881
|
+
if (sealedCalendar !== undefined) {
|
|
882
|
+
rememberSealedInvite(message.uid, sealedCalendar);
|
|
883
|
+
}
|
|
884
|
+
const mayHoldInvite = !message.encrypted &&
|
|
885
|
+
!inOutbox &&
|
|
886
|
+
message.folderUid !== draftsFolderUid &&
|
|
887
|
+
(attachments.some(isCalendarAttachment) || sealedCalendar !== undefined);
|
|
867
888
|
// The calendar file of a message whose invitation card is drawn is the card, not a chip in the attachment list (a nameless "attachment" to click):
|
|
868
889
|
// it is listed again if the card could not be drawn (no invitation the server can read, or the lookup failed), so the file is never out of reach.
|
|
869
890
|
const { invite: shownInvite } = useMessageInvite(message.uid, mayHoldInvite);
|
|
@@ -934,7 +955,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
934
955
|
/** The rules page's "New mail filter", started from this message: its sender and (for a message the server can read) its subject. */
|
|
935
956
|
function handleCreateRule() {
|
|
936
957
|
const params = new URLSearchParams({ mailboxUid: message.mailboxUid, from: message.from.address });
|
|
937
|
-
const subject = message.encrypted ? "" : withoutSubjectPrefixes(message.subject);
|
|
958
|
+
const subject = message.encrypted || message.zeroAccess ? "" : withoutSubjectPrefixes(message.subject);
|
|
938
959
|
if (subject) {
|
|
939
960
|
params.set("subject", subject);
|
|
940
961
|
}
|
|
@@ -959,7 +980,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
959
980
|
createRule: handleCreateRule,
|
|
960
981
|
};
|
|
961
982
|
// An encrypted message can be printed once it has been opened here: while it is still opening, or when it can't be, there is nothing to print.
|
|
962
|
-
const printReason = !message.encrypted
|
|
983
|
+
const printReason = !message.encrypted && !message.zeroAccess
|
|
963
984
|
? undefined
|
|
964
985
|
: security === null
|
|
965
986
|
? "Still opening this message"
|
|
@@ -970,7 +991,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
970
991
|
: "This message can't be read, so it can't be printed";
|
|
971
992
|
// In Junk Email the card's button is the way back (Not junk), which only needs the right to change the message.
|
|
972
993
|
const reportReason = !writable ? "View-only mailbox" : undefined;
|
|
973
|
-
const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [_jsx(SenderAvatar, { from: senderRecipient }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (_jsxs("h2", { className: "relative min-w-0 flex-1", style: BODY_FONT_STYLE, children: [_jsx("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, "aria-label": `${cardUnread ? "Unread. " : ""}${senderLabel}`, className: ["absolute inset-0 w-full rounded-sm", ROW_FOCUS_CLASS].join(" ") }), _jsx("span", { className: "pointer-events-none relative flex text-sm", children: _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, label: `Contact card for ${senderLabel}`, className: "pointer-events-auto min-w-0 max-w-full", children: _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) }) }) })] })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From", " ", _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc"), context: cardContext }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc"), context: cardContext }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc"), context: cardContext })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
|
|
994
|
+
const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [_jsx(SenderAvatar, { from: senderRecipient }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (_jsxs("h2", { className: "relative min-w-0 flex-1", style: BODY_FONT_STYLE, children: [_jsx("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, "aria-label": `${cardUnread ? "Unread. " : ""}${senderLabel}`, className: ["absolute inset-0 w-full rounded-sm", ROW_FOCUS_CLASS].join(" ") }), _jsx("span", { className: "pointer-events-none relative flex text-sm", children: _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, label: `Contact card for ${senderLabel}`, className: "pointer-events-auto min-w-0 max-w-full", children: _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) }) }) })] })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From", " ", _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security, zeroAccess: !!message.zeroAccess })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc"), context: cardContext }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc"), context: cardContext }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc"), context: cardContext })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
|
|
974
995
|
"inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt",
|
|
975
996
|
viewOriginal ? "bg-primary/10" : "",
|
|
976
997
|
].join(" "), children: viewOriginal ? _jsx(HiOutlineMoon, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineSun, { size: 16, "aria-hidden": "true" }) })), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", shortcut: keyboard ? SHORTCUTS.mail.reply : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", shortcut: keyboard ? SHORTCUTS.mail.replyAll : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", shortcut: keyboard ? SHORTCUTS.mail.forward : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("forward") }), archivable && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", shortcut: keyboard ? SHORTCUTS.mail.archive : undefined, busy: archiving, disabled: archiving, onClick: handleArchive })), movable && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", shortcut: keyboard ? SHORTCUTS.mail.move : undefined, onClick: () => setMovePrompt(true) })), reportable && (_jsx(IconAction, { icon: inJunk ? _jsx(HiOutlineInboxArrowDown, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineNoSymbol, { size: 16, "aria-hidden": "true" }), label: inJunk ? "Not junk" : "Report junk", busy: messageActions.busy, disabled: messageActions.busy || reportReason !== undefined, reason: reportReason, onClick: () => void (inJunk ? messageActions.notJunk() : messageActions.reportJunk()) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } })), archivable && (_jsx(MessageMoreMenu, { actions: menuActions, senderAddress: message.from.address, read: currentLabelsMessage.flags.read === true, flagged: currentLabelsMessage.flags.flagged === true, writable: writable, busy: messageActions.busy, inJunk: inJunk, inDeletedItems: inDeletedItems, sent: !!isSentItems, ownSender: readerOwnAddresses.includes(message.from.address.toLowerCase()), canTrustSender: writable, printReason: printReason, composing: preparingCompose, triggerClassName: ICON_BUTTON_CLASS }))] })] }), (answered || forwarded) && (_jsx("p", { className: "px-4 py-1.5 text-xs text-text-muted bg-surface-alt border-y border-border", children: answered && forwarded ? "You replied to and forwarded this message." : answered ? "You replied to this message." : "You forwarded this message." })), _jsxs("div", { id: threadHeader?.bodyId, className: "px-4 pb-3 flex flex-col gap-2", children: [inThread && (subjectDiffers || !sameSubject(shownSubject, threadSubject)) && (_jsx("h3", { className: "text-sm font-semibold break-words", children: shownSubject })), _jsx("div", { className: "flex flex-wrap items-center gap-2 empty:hidden", children: sendingStatus }), inOutbox && message.scheduledSendError && _jsxs(Alert, { children: ["This message wasn\u2019t sent: ", message.scheduledSendError] }), cancelError && _jsx(Alert, { children: cancelError }), keyChangeNotice && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: keyChangeNotice })), security && UNVERIFIED_SIGNER_STATES.has(security.state) && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: ["Signed, but the signer isn\u2019t a trusted contact key, so the sender isn\u2019t verified.", security.signerEmails && security.signerEmails.length > 0 && _jsxs(_Fragment, { children: [" Certificate for ", security.signerEmails.join(", "), "."] }), security.signerFingerprint && (_jsxs(_Fragment, { children: [" ", "Fingerprint ", _jsx("span", { className: "font-mono text-xs break-all", children: security.signerFingerprint }), "."] }))] })), pendingConflict && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["This sender has a signing key change waiting for your review, so this signer can\u2019t be trusted from here.", " ", _jsx("a", { href: `/contacts/${encodeURIComponent(senderKeyState.contactUid)}`, className: "font-medium text-primary-dark hover:underline", children: "Review it in Contacts" })] })), trustableCertificate && (_jsx("div", { children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => {
|
|
@@ -990,6 +1011,6 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
990
1011
|
.filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
|
|
991
1012
|
.map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " 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(Alert, { children: receiptError }), mayHoldInvite && _jsx(InviteCard, { messageUid: message.uid, headingLevel: inThread ? 3 : 2 }), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
|
|
992
1013
|
? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: isVCardAttachment({ filename: attachment.filename, mimeType: attachment.contentType }) ? (_jsx(VCardAttachmentChip, { label: attachment.filename ?? `Unnamed ${attachment.contentType} attachment`, message: message, loadText: async () => new TextDecoder().decode(attachment.decode() ?? new Uint8Array(0)), onDownload: () => downloadMimeAttachment(attachment) })) : (_jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), 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 ?? `Unnamed ${attachment.contentType} attachment` })) }, `${index}:${attachment.filename ?? ""}`))) }))
|
|
993
|
-
: listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid, client), onDownload: () => saveAttachment(attachment) })) : client ? (_jsxs("button", { type: "button", onClick: () => saveAttachment(attachment), 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), ")"] })) : (_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))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
|
|
994
|
-
return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), messageActions.dialog, _jsx(MessageSourceDialog, { open: sourceView !== null, onClose: closeSource, mode: sourceView?.mode ?? "source", source: sourceView?.source ?? { status: "loading" }, encrypted: !!message.encrypted }), _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" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
|
|
1014
|
+
: listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid, client), onDownload: () => saveAttachment(attachment) })) : client ? (_jsxs("button", { type: "button", onClick: () => saveAttachment(attachment), 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), ")"] })) : (_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))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: (message.encrypted || message.zeroAccess) && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
|
|
1015
|
+
return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), messageActions.dialog, _jsx(MessageSourceDialog, { open: sourceView !== null, onClose: closeSource, mode: sourceView?.mode ?? "source", source: sourceView?.source ?? { status: "loading" }, encrypted: !!message.encrypted || !!message.zeroAccess }), _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" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
|
|
995
1016
|
}
|
|
@@ -5,6 +5,8 @@ import type { ResumeCompose } from "../../../mail/outbox/composeBridge.js";
|
|
|
5
5
|
* What the compose window needs to continue `draft` - a message in the Drafts folder the reader opened rather than one a window of this tab
|
|
6
6
|
* saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
|
|
7
7
|
* and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
|
|
8
|
+
* A draft saved in a zero-access mailbox is stored sealed to the mailbox's zero-access key: it is decrypted here with the unlocked keys (its
|
|
9
|
+
* subject comes from the decrypted message too, the record holding only `[...]`), and is `undefined` - left to read - while they're locked.
|
|
8
10
|
* Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
|
|
9
11
|
*/
|
|
10
12
|
export declare function resumeFromDraft(draft: Message, client?: ApiClient): Promise<ResumeCompose | undefined>;
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import {
|
|
5
|
+
import { getUnlockedKeys } from "../../../../../lib/crypto/keySession.js";
|
|
6
|
+
import { evaluateMessageSecurity } from "../../../../../lib/crypto/messageSecurity.js";
|
|
7
|
+
import { getMessageRawContent, listAttachments } from "../../../../../lib/mail/mailApi.js";
|
|
6
8
|
import { formatMailAddress } from "../../../../../lib/mail/mailAddress.js";
|
|
7
9
|
import { loadOriginalMessage } from "./quotedBody.js";
|
|
8
10
|
/** The comma-separated address text a recipient field holds, for the recipients of one type. */
|
|
@@ -24,13 +26,22 @@ function textToHtml(text) {
|
|
|
24
26
|
* What the compose window needs to continue `draft` - a message in the Drafts folder the reader opened rather than one a window of this tab
|
|
25
27
|
* saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
|
|
26
28
|
* and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
|
|
29
|
+
* A draft saved in a zero-access mailbox is stored sealed to the mailbox's zero-access key: it is decrypted here with the unlocked keys (its
|
|
30
|
+
* subject comes from the decrypted message too, the record holding only `[...]`), and is `undefined` - left to read - while they're locked.
|
|
27
31
|
* Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
|
|
28
32
|
*/
|
|
29
33
|
export async function resumeFromDraft(draft, client) {
|
|
30
34
|
if (draft.encrypted) {
|
|
31
35
|
return undefined;
|
|
32
36
|
}
|
|
33
|
-
|
|
37
|
+
let security = null;
|
|
38
|
+
if (draft.zeroAccess) {
|
|
39
|
+
security = await openSealedDraft(draft, client);
|
|
40
|
+
if (!security) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const [original, attachments] = await Promise.all([loadOriginalMessage(draft, security), listAttachments(draft.folderUid, draft.uid, client)]);
|
|
34
45
|
const html = original.body.html !== undefined ? original.body.html : original.body.text !== undefined ? textToHtml(original.body.text) : textToHtml(draft.bodyPreview ?? "");
|
|
35
46
|
return {
|
|
36
47
|
draft,
|
|
@@ -38,7 +49,7 @@ export async function resumeFromDraft(draft, client) {
|
|
|
38
49
|
to: recipientText(draft.recipients, "to"),
|
|
39
50
|
cc: recipientText(draft.recipients, "cc"),
|
|
40
51
|
bcc: recipientText(draft.recipients, "bcc"),
|
|
41
|
-
subject: draft.subject,
|
|
52
|
+
subject: security?.subject ?? draft.subject,
|
|
42
53
|
html,
|
|
43
54
|
attachments,
|
|
44
55
|
requestReceipt: !!draft.requestReceipt,
|
|
@@ -46,3 +57,17 @@ export async function resumeFromDraft(draft, client) {
|
|
|
46
57
|
encryptRequested: false,
|
|
47
58
|
};
|
|
48
59
|
}
|
|
60
|
+
/** `draft`'s sealed message decrypted with its mailbox's unlocked keys, or `null` when they're locked or it won't open with them. */
|
|
61
|
+
async function openSealedDraft(draft, client) {
|
|
62
|
+
const unlocked = getUnlockedKeys(draft.mailboxUid);
|
|
63
|
+
if (!unlocked) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const security = await evaluateMessageSecurity(await getMessageRawContent(draft.uid, client), unlocked);
|
|
68
|
+
return security.zeroAccess ? security : null;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -18,7 +18,7 @@ export declare const QUOTE_CACHE_MS = 30000;
|
|
|
18
18
|
/**
|
|
19
19
|
* Starts fetching what a Reply/Forward of `message` will quote, without waiting for the click - called when the pointer or
|
|
20
20
|
* keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
|
|
21
|
-
* Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
|
|
21
|
+
* Nothing to fetch for an encrypted or zero-access message (`loadOriginalMessage()` never asks the server for one's body).
|
|
22
22
|
*/
|
|
23
23
|
export declare function prefetchOriginalMessage(message: Message, client?: ApiClient): void;
|
|
24
24
|
/** Forgets every remembered body (the sign-out and test seam). */
|
|
@@ -78,10 +78,10 @@ function withTimeout(promise, ms) {
|
|
|
78
78
|
/**
|
|
79
79
|
* Starts fetching what a Reply/Forward of `message` will quote, without waiting for the click - called when the pointer or
|
|
80
80
|
* keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
|
|
81
|
-
* Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
|
|
81
|
+
* Nothing to fetch for an encrypted or zero-access message (`loadOriginalMessage()` never asks the server for one's body).
|
|
82
82
|
*/
|
|
83
83
|
export function prefetchOriginalMessage(message, client) {
|
|
84
|
-
if (!message.encrypted) {
|
|
84
|
+
if (!message.encrypted && !message.zeroAccess) {
|
|
85
85
|
void fetchContentHtml(message.uid, client);
|
|
86
86
|
}
|
|
87
87
|
}
|
|
@@ -173,7 +173,8 @@ export async function loadOriginalMessage(message, security, options = {}, clien
|
|
|
173
173
|
? [...headerRecipients(protectedHeaders.to, "to"), ...headerRecipients(protectedHeaders.cc, "cc")]
|
|
174
174
|
: [];
|
|
175
175
|
const result = { body: recovered ?? {}, ...(protectedRecipients.length > 0 ? { recipients: protectedRecipients } : {}) };
|
|
176
|
-
|
|
176
|
+
// The server holds only ciphertext of an encrypted or zero-access message: its body is what `security` decrypted, or nothing.
|
|
177
|
+
if (message.encrypted || message.zeroAccess) {
|
|
177
178
|
return result;
|
|
178
179
|
}
|
|
179
180
|
let needsRaw = !!options.recipients && result.recipients === undefined;
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import { useCallback, useEffect, useReducer } from "react";
|
|
6
6
|
import { getMessageInvite } from "../../../../../lib/calendar/inviteApi.js";
|
|
7
|
+
import { clearSealedInvites, sealedInviteCalendar } from "../../../../../lib/calendar/sealedInvites.js";
|
|
8
|
+
import { onSigningOut } from "../compose/composeFlushRegistry.js";
|
|
7
9
|
import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
|
|
8
10
|
/**
|
|
9
11
|
* A per-message cache of the calendar invitation the server reads out of a message, shared by everything that draws one: the reading pane's
|
|
@@ -37,7 +39,9 @@ function normalize(invite) {
|
|
|
37
39
|
*/
|
|
38
40
|
export function loadInvite(uid, client) {
|
|
39
41
|
const existing = entries.get(uid);
|
|
40
|
-
|
|
42
|
+
// "No invitation" for a zero-access message is asked again once its calendar file has been decrypted here (the list asks before that).
|
|
43
|
+
const stale = existing?.value === null && sealedInviteCalendar(uid) !== undefined;
|
|
44
|
+
if (existing && !stale && (existing.value === undefined || Date.now() - existing.at < INVITE_TTL_MS)) {
|
|
41
45
|
return existing.promise;
|
|
42
46
|
}
|
|
43
47
|
const entry = {
|
|
@@ -100,3 +104,4 @@ export function useMessageInvite(uid, enabled = true) {
|
|
|
100
104
|
const value = enabled ? entries.get(uid)?.value : undefined;
|
|
101
105
|
return { invite: value ?? null, settled: value !== undefined, setInvite };
|
|
102
106
|
}
|
|
107
|
+
onSigningOut(clearSealedInvites);
|