@rapidmx/web-client 0.34.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/calendarLiveUpdates.ts +2 -5
- package/apps/shared/calendar/sealedEvents.ts +80 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +8 -4
- 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 +91 -20
- package/apps/shared/components/mail/compose/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/quotedBody.ts +31 -15
- 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/MessageBody.tsx +4 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
- package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
- 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/live/useFolderLiveUpdates.ts +59 -0
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +2 -2
- package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
- 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 +180 -58
- package/apps/www/contacts/index.tsx +58 -2
- 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/apps/www/tasks/index.tsx +42 -0
- package/dist/apps/escrow/matters/[uid].js +2 -2
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
- 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/ContactsSidebar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
- 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 +73 -17
- 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 +4 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -15
- 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/MessageBody.js +4 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
- package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
- 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/live/useFolderLiveUpdates.d.ts +19 -0
- package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -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/outbox/sendOutcomes.js +1 -3
- 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 +48 -22
- package/dist/apps/www/contacts/index.js +49 -3
- 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/apps/www/tasks/index.js +29 -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/mail/pushClient.d.ts +10 -3
- package/dist/lib/mail/pushClient.js +18 -4
- package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -0
- package/dist/lib/tasks/tasksApi.d.ts +1 -0
- package/dist/lib/tasks/tasksApi.js +3 -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/mail/pushClient.ts +18 -4
- package/lib/search/zeroAccessIndexApi.ts +119 -0
- package/lib/tasks/tasksApi.ts +4 -0
- package/package.json +3 -3
|
@@ -23,28 +23,39 @@ export function cachedBodyContent(uid, version) {
|
|
|
23
23
|
/**
|
|
24
24
|
* The message's body from the server's `GET /mail/messages/:id/content`: the HTML the server sanitized when it ingested the message
|
|
25
25
|
* (`sanitizedHtmlBlobKey`), or - for a message with none - the plain-text preview. A request that fails throws an `ApiRequestError`.
|
|
26
|
-
* Successful answers are remembered per uid and version.
|
|
26
|
+
* Successful answers are remembered per uid and version. With an explicit `client` the request goes through it (its origin and token) instead of
|
|
27
|
+
* the `jwt` cookie; without one it is the cookie request it always was.
|
|
27
28
|
*/
|
|
28
|
-
export async function fetchBodyContent(uid, version, signal) {
|
|
29
|
+
export async function fetchBodyContent(uid, version, signal, client) {
|
|
29
30
|
const cached = cachedBodyContent(uid, version);
|
|
30
31
|
if (cached) {
|
|
31
32
|
return cached;
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
|
|
42
|
-
throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
|
|
34
|
+
const path = `/mail/messages/${encodeURIComponent(uid)}/content`;
|
|
35
|
+
let text;
|
|
36
|
+
let contentType;
|
|
37
|
+
if (client) {
|
|
38
|
+
// `fetchBlob()` rejects with the same `ApiRequestError` a failed response gives, and its blob carries the response's own type.
|
|
39
|
+
const blob = await client.fetchBlob(path, { headers: { Accept: ACCEPT }, signal });
|
|
40
|
+
text = await blob.text();
|
|
41
|
+
contentType = blob.type;
|
|
43
42
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
else {
|
|
44
|
+
// Says what it can show, as a document request would: the server answers with the sanitized HTML, or its plain-text fallback.
|
|
45
|
+
const res = await fetch(apiUrl(path), {
|
|
46
|
+
credentials: "include",
|
|
47
|
+
headers: { Accept: ACCEPT },
|
|
48
|
+
signal,
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
const resType = res.headers.get("content-type") ?? "";
|
|
52
|
+
const body = resType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
|
|
53
|
+
throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
|
|
54
|
+
}
|
|
55
|
+
text = await res.text();
|
|
56
|
+
contentType = res.headers.get("content-type") ?? "";
|
|
57
|
+
}
|
|
58
|
+
const content = contentType.toLowerCase().includes("text/html") ? { kind: "html", html: text } : { kind: "text", text };
|
|
48
59
|
if (cache.size >= CACHE_LIMIT) {
|
|
49
60
|
cache.delete(cache.keys().next().value);
|
|
50
61
|
}
|
|
@@ -10,6 +10,7 @@ export declare function printHeaders(message: Message, from: string): PrintableM
|
|
|
10
10
|
export declare function olderConversationMessages(message: Message, client?: ApiClient): Promise<Message[]>;
|
|
11
11
|
/**
|
|
12
12
|
* One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
|
|
13
|
-
* its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
|
|
13
|
+
* its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
|
|
14
|
+
* request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
|
|
14
15
|
*/
|
|
15
|
-
export declare function printableOlderMessage(message: Message): Promise<PrintableMessage>;
|
|
16
|
+
export declare function printableOlderMessage(message: Message, client?: ApiClient): Promise<PrintableMessage>;
|
|
@@ -34,15 +34,16 @@ export async function olderConversationMessages(message, client) {
|
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
36
|
* One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
|
|
37
|
-
* its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
|
|
37
|
+
* its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
|
|
38
|
+
* request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
|
|
38
39
|
*/
|
|
39
|
-
export async function printableOlderMessage(message) {
|
|
40
|
-
const attachments = await listAttachments(message.folderUid, message.uid).catch(() => []);
|
|
40
|
+
export async function printableOlderMessage(message, client) {
|
|
41
|
+
const attachments = await listAttachments(message.folderUid, message.uid, client).catch(() => []);
|
|
41
42
|
if (message.encrypted) {
|
|
42
43
|
const unlocked = getUnlockedKeys(message.mailboxUid);
|
|
43
44
|
if (unlocked) {
|
|
44
45
|
const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
|
|
45
|
-
const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid), unlocked);
|
|
46
|
+
const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid, client), unlocked);
|
|
46
47
|
const content = security.text !== undefined ? { kind: "text", text: security.text } : security.html !== undefined ? { kind: "html", html: security.html } : undefined;
|
|
47
48
|
if (content) {
|
|
48
49
|
return {
|
|
@@ -63,7 +64,8 @@ export async function printableOlderMessage(message) {
|
|
|
63
64
|
return {
|
|
64
65
|
subject: message.subject || "(no subject)",
|
|
65
66
|
headers: printHeaders(message, formatMailAddress(message.from)),
|
|
66
|
-
|
|
67
|
+
// Without a client this is exactly the cookie request it always was.
|
|
68
|
+
content: await (client ? fetchBodyContent(message.uid, message.version, undefined, client) : fetchBodyContent(message.uid, message.version)),
|
|
67
69
|
attachments,
|
|
68
70
|
};
|
|
69
71
|
}
|
|
@@ -11,6 +11,7 @@ import { resolveFolderOfType } from "../../../mail/folderOfType.js";
|
|
|
11
11
|
import { setReadStateMany } from "../../../mail/messageReadState.js";
|
|
12
12
|
import { usePermanentDelete } from "../../../mail/usePermanentDelete.js";
|
|
13
13
|
import { reportFolderName, reportNotice, reportNoun, reportTitle, senderListsHref } from "../../../mail/reportNotices.js";
|
|
14
|
+
import { zeroAccessSourceBase64 } from "../../../mail/zeroAccessSource.js";
|
|
14
15
|
import { blockSender, neverBlockSender, normalizeAddresses, removeSenderRule } from "../../../mail/senderBlocking.js";
|
|
15
16
|
import { notifyApiError } from "../../../notifications/apiErrors.js";
|
|
16
17
|
import { notify } from "../../../notifications/store.js";
|
|
@@ -122,7 +123,9 @@ export function useMessageActions(params) {
|
|
|
122
123
|
await exclusive(async () => {
|
|
123
124
|
let result;
|
|
124
125
|
try {
|
|
125
|
-
|
|
126
|
+
// The server holds only the sealed copy of a zero-access message: its spam filter learns from the source decrypted here.
|
|
127
|
+
const rawMessage = await zeroAccessSourceBase64(message, client);
|
|
128
|
+
result = await reportMessage(message.uid, kind, { alwaysTrustSender, ...(rawMessage ? { rawMessage } : {}) }, client);
|
|
126
129
|
}
|
|
127
130
|
catch (err) {
|
|
128
131
|
if (isNotFound(err)) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
3
|
+
export interface KeyExportSettingProps {
|
|
4
|
+
mailbox: Mailbox;
|
|
5
|
+
/** The caller owns the mailbox and isn't impersonating: restapi records an export only for the owner. */
|
|
6
|
+
canManage: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* "Export keys for Outlook/phone" (`lib/crypto/keyExport.ts`): a passphrase-protected `.p12` with the mailbox's zero-access
|
|
10
|
+
* and end-to-end encryption keys - and its signing key, if the user ticks the box - so a mail app that isn't RapidMX can read
|
|
11
|
+
* mail stored encrypted. Asks for the master password again however long the mailbox has been unlocked.
|
|
12
|
+
*/
|
|
13
|
+
export default function KeyExportSetting({ mailbox, canManage }: KeyExportSettingProps): React.JSX.Element;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { exportKeysAsPkcs12, MIN_EXPORT_PASSPHRASE_LENGTH, NothingToExportError } from "../../../../lib/crypto/keyExport.js";
|
|
8
|
+
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
9
|
+
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
10
|
+
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
11
|
+
import Button from "../../../../lib/components/buttons/Button.js";
|
|
12
|
+
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
13
|
+
import PolicyLockedNotice from "./PolicyLockedNotice.js";
|
|
14
|
+
const INPUT_CLASS = "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface";
|
|
15
|
+
/** Hands `bytes` to the browser as a file download. */
|
|
16
|
+
function saveFile(bytes, filename) {
|
|
17
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: "application/x-pkcs12" }));
|
|
18
|
+
const link = document.createElement("a");
|
|
19
|
+
link.href = url;
|
|
20
|
+
link.download = filename;
|
|
21
|
+
document.body.appendChild(link);
|
|
22
|
+
link.click();
|
|
23
|
+
link.remove();
|
|
24
|
+
URL.revokeObjectURL(url);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* "Export keys for Outlook/phone" (`lib/crypto/keyExport.ts`): a passphrase-protected `.p12` with the mailbox's zero-access
|
|
28
|
+
* and end-to-end encryption keys - and its signing key, if the user ticks the box - so a mail app that isn't RapidMX can read
|
|
29
|
+
* mail stored encrypted. Asks for the master password again however long the mailbox has been unlocked.
|
|
30
|
+
*/
|
|
31
|
+
export default function KeyExportSetting({ mailbox, canManage }) {
|
|
32
|
+
const client = useApiClient();
|
|
33
|
+
const [open, setOpen] = useState(false);
|
|
34
|
+
const [password, setPassword] = useState("");
|
|
35
|
+
const [passphrase, setPassphrase] = useState("");
|
|
36
|
+
const [confirmPassphrase, setConfirmPassphrase] = useState("");
|
|
37
|
+
const [includeSigningKey, setIncludeSigningKey] = useState(false);
|
|
38
|
+
const [exporting, setExporting] = useState(false);
|
|
39
|
+
const [error, setError] = useState(null);
|
|
40
|
+
function reset() {
|
|
41
|
+
setOpen(false);
|
|
42
|
+
setPassword("");
|
|
43
|
+
setPassphrase("");
|
|
44
|
+
setConfirmPassphrase("");
|
|
45
|
+
setIncludeSigningKey(false);
|
|
46
|
+
setError(null);
|
|
47
|
+
}
|
|
48
|
+
async function handleExport(e) {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
if (passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) {
|
|
51
|
+
setError(`The file's passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters.`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (passphrase !== confirmPassphrase) {
|
|
55
|
+
setError("The passphrases don't match.");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
setExporting(true);
|
|
59
|
+
setError(null);
|
|
60
|
+
try {
|
|
61
|
+
const result = await exportKeysAsPkcs12({
|
|
62
|
+
mailboxUid: mailbox.uid,
|
|
63
|
+
mailboxAddress: mailbox.primarySmtpAddress,
|
|
64
|
+
mailboxKeys: mailbox.keys ?? [],
|
|
65
|
+
masterPassword: password,
|
|
66
|
+
exportPassphrase: passphrase,
|
|
67
|
+
includeSigningKey,
|
|
68
|
+
}, client);
|
|
69
|
+
saveFile(result.pkcs12, `${mailbox.primarySmtpAddress}-keys.p12`);
|
|
70
|
+
reset();
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err instanceof ApiRequestError) {
|
|
74
|
+
notifyApiError(err, "Couldn't export your keys");
|
|
75
|
+
}
|
|
76
|
+
else if (err instanceof NothingToExportError || (err instanceof Error && err.message.startsWith("This mailbox has no password"))) {
|
|
77
|
+
setError(err.message);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
setError("Incorrect password.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
setExporting(false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Export keys for Outlook or a phone" }), _jsx("p", { className: "text-sm text-text-muted mb-2", children: "Mail apps other than RapidMX need your keys to read encrypted mail. The exported file holds your zero-access and end-to-end encryption keys, protected by a passphrase you choose. Anyone with the file and its passphrase can read all of this mailbox\u2019s mail, so keep it safe and delete it once it\u2019s installed." }), !canManage ? (_jsx(PolicyLockedNotice, { children: "Only this mailbox\u2019s owner, signed in as themselves, can export its keys." })) : !open ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setOpen(true), children: "Export keys\u2026" })) : (_jsxs("form", { onSubmit: (e) => void handleExport(e), className: "flex flex-col gap-2", children: [_jsx("input", { type: "password", "aria-label": "Encryption password", placeholder: "Encryption password", className: INPUT_CLASS, value: password, onChange: (e) => setPassword(e.target.value), disabled: exporting, autoComplete: "current-password" }), _jsx("input", { type: "password", "aria-label": "File passphrase", placeholder: `File passphrase (at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters)`, className: INPUT_CLASS, value: passphrase, onChange: (e) => setPassphrase(e.target.value), disabled: exporting, autoComplete: "new-password" }), _jsx("input", { type: "password", "aria-label": "Confirm file passphrase", placeholder: "Confirm file passphrase", className: INPUT_CLASS, value: confirmPassphrase, onChange: (e) => setConfirmPassphrase(e.target.value), disabled: exporting, autoComplete: "new-password" }), _jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: includeSigningKey, onChange: (e) => setIncludeSigningKey(e.target.checked), disabled: exporting }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: "Include my signing key" }), _jsx("span", { className: "text-xs text-text-muted", children: "Only if that app will sign mail you send. Anyone with the file could sign as you." })] })] }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "submit", className: "!w-auto", disabled: exporting || !password || !passphrase, children: exporting ? "Exporting…" : "Export" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: reset, disabled: exporting, children: "Cancel" })] })] }))] }));
|
|
88
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface PolicyLockedNoticeProps {
|
|
3
|
+
/** Why the setting next to this notice can't be changed - e.g. that an administrator turned it off for the server. */
|
|
4
|
+
children: React.ReactNode;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Explains why a settings control is disabled by a policy the user can't change themselves (an administrator's server-wide
|
|
8
|
+
* setting, say), shown right under that control. A disabled control with no reason reads as a defect; this says who decided
|
|
9
|
+
* and that the user isn't the one who can undo it.
|
|
10
|
+
*/
|
|
11
|
+
export default function PolicyLockedNotice({ children }: PolicyLockedNoticeProps): React.JSX.Element;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Explains why a settings control is disabled by a policy the user can't change themselves (an administrator's server-wide
|
|
4
|
+
* setting, say), shown right under that control. A disabled control with no reason reads as a defect; this says who decided
|
|
5
|
+
* and that the user isn't the one who can undo it.
|
|
6
|
+
*/
|
|
7
|
+
export default function PolicyLockedNotice({ children }) {
|
|
8
|
+
return (_jsx("p", { role: "note", className: "mt-1.5 text-xs text-text-muted border-l-2 border-border pl-2", children: children }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
3
|
+
export interface ZeroAccessSettingProps {
|
|
4
|
+
mailbox: Mailbox;
|
|
5
|
+
/** The caller owns the mailbox and isn't impersonating: only then can the setting be changed (restapi drops anybody else's value). */
|
|
6
|
+
canManage: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* "Enable zero-access encryption" for one mailbox (restapi `specs/search.md` §3): unencrypted mail is encrypted to the
|
|
10
|
+
* mailbox's zero-access key as it arrives and the server keeps no readable copy. On by default. Shown disabled, with the
|
|
11
|
+
* reason, when the administrator has turned "Allow zero-access encryption" off for the server, or when the server doesn't
|
|
12
|
+
* support it. A shared mailbox is covered too: its members read it through member key wraps (`sharedMailboxKeys.ts`).
|
|
13
|
+
*/
|
|
14
|
+
export default function ZeroAccessSetting({ mailbox, canManage }: ZeroAccessSettingProps): React.JSX.Element;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { getEncryptionPolicy } from "../../../../lib/crypto/keyvaultApi.js";
|
|
8
|
+
import { getMailbox, updateMailbox } from "../../../../lib/mail/mailApi.js";
|
|
9
|
+
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
10
|
+
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
11
|
+
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
12
|
+
import PolicyLockedNotice from "./PolicyLockedNotice.js";
|
|
13
|
+
/**
|
|
14
|
+
* "Enable zero-access encryption" for one mailbox (restapi `specs/search.md` §3): unencrypted mail is encrypted to the
|
|
15
|
+
* mailbox's zero-access key as it arrives and the server keeps no readable copy. On by default. Shown disabled, with the
|
|
16
|
+
* reason, when the administrator has turned "Allow zero-access encryption" off for the server, or when the server doesn't
|
|
17
|
+
* support it. A shared mailbox is covered too: its members read it through member key wraps (`sharedMailboxKeys.ts`).
|
|
18
|
+
*/
|
|
19
|
+
export default function ZeroAccessSetting({ mailbox, canManage }) {
|
|
20
|
+
const client = useApiClient();
|
|
21
|
+
const [enabled, setEnabled] = useState(mailbox.zeroAccessEnabled !== false);
|
|
22
|
+
const [blocker, setBlocker] = useState(undefined);
|
|
23
|
+
const [saving, setSaving] = useState(false);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
let cancelled = false;
|
|
26
|
+
void (async () => {
|
|
27
|
+
const policy = await getEncryptionPolicy(client).catch(() => undefined);
|
|
28
|
+
let next = null;
|
|
29
|
+
if (policy?.allowZeroAccess === undefined) {
|
|
30
|
+
next = "unsupported";
|
|
31
|
+
}
|
|
32
|
+
else if (!policy.allowZeroAccess) {
|
|
33
|
+
next = "disabled_by_admin";
|
|
34
|
+
}
|
|
35
|
+
if (!cancelled) {
|
|
36
|
+
setBlocker(next);
|
|
37
|
+
}
|
|
38
|
+
})();
|
|
39
|
+
return () => {
|
|
40
|
+
cancelled = true;
|
|
41
|
+
};
|
|
42
|
+
}, [mailbox.uid, client]);
|
|
43
|
+
async function save(next) {
|
|
44
|
+
setSaving(true);
|
|
45
|
+
const previous = enabled;
|
|
46
|
+
setEnabled(next);
|
|
47
|
+
try {
|
|
48
|
+
const write = (version) => updateMailbox({ uid: mailbox.uid, version, zeroAccessEnabled: next }, client);
|
|
49
|
+
try {
|
|
50
|
+
await write(mailbox.version);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
// Enrolling a key (which happens on unlock) moves the mailbox's version on, so the copy this page was given may be
|
|
54
|
+
// behind: read it again and retry once.
|
|
55
|
+
if (!(err instanceof ApiRequestError && err.status === 409)) {
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
await write((await getMailbox(mailbox.uid, {}, client)).version);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
setEnabled(previous);
|
|
63
|
+
notifyApiError(err, "Couldn't save the zero-access encryption setting");
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
setSaving(false);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const locked = blocker !== null;
|
|
70
|
+
return (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Zero-access encryption" }), _jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: !locked && enabled, disabled: !canManage || locked || saving, onChange: (e) => void save(e.target.checked) }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: "Enable zero-access encryption" }), _jsx("span", { className: "text-xs text-text-muted", children: "Mail that arrives unencrypted is encrypted the moment it is received, and the server keeps no copy it can read - nobody else, administrators included, can read it. You unlock it with your encryption password, and search of message contents happens on your device. Outlook and phone mail apps need your exported keys to read it. Turning this off only affects mail that arrives afterwards." })] })] }), blocker === "disabled_by_admin" && (_jsx(PolicyLockedNotice, { children: "Your administrator has turned off zero-access encryption for this server, so new mail is stored without it. Mail already encrypted stays encrypted." })), blocker === "unsupported" && _jsx(PolicyLockedNotice, { children: "This server doesn\u2019t offer zero-access encryption." }), !mailbox.ownerUserUid && !locked && (_jsx(PolicyLockedNotice, { children: "This shared mailbox has no owner: it is protected for its members, whose apps share its key with each other." })), !canManage && !locked && mailbox.ownerUserUid && (_jsx(PolicyLockedNotice, { children: "Only this mailbox\u2019s owner, signed in as themselves, can change this." }))] }));
|
|
71
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type PushEvent } from "../../../lib/mail/pushClient.js";
|
|
2
|
+
/** An item a push event says was created, changed or deleted: its uid, and whether it is gone. */
|
|
3
|
+
export interface FolderItemChange {
|
|
4
|
+
uid: string;
|
|
5
|
+
deleted: boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The item a push event says was created, changed or deleted, when the event's `type` - the published model class name (`ContactMongo`,
|
|
9
|
+
* `TaskSQL`, ...) - matches `model`, or `undefined` for anything else the shared push connection delivers. Only the uid is taken: the payload
|
|
10
|
+
* is whatever every subscriber of the folder may see (a private calendar event's busy block), so a page fetches the item again by its uid.
|
|
11
|
+
*/
|
|
12
|
+
export declare function changedItemOf(event: PushEvent, model: RegExp): FolderItemChange | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Keeps a page's folders live: while the page is mounted the tab's push connection is subscribed to `folderUids` (under the page's own
|
|
15
|
+
* `group` - see `PushClient.setChannels()`; Mail's connection subscribes to mail folders only, so nothing else asks for a calendar,
|
|
16
|
+
* contacts or tasks folder), and `onChange` is called with every item `parse` recognizes - created, changed or deleted anywhere: another
|
|
17
|
+
* tab, a phone over ActiveSync, the server itself. `onChange` may change between renders; the latest is called.
|
|
18
|
+
*/
|
|
19
|
+
export declare function useFolderLiveUpdates(group: string, folderUids: readonly string[], parse: (event: PushEvent) => FolderItemChange | undefined, onChange: (change: FolderItemChange) => void): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
import { getPushClient } from "../../../lib/mail/pushClient.js";
|
|
7
|
+
/**
|
|
8
|
+
* The item a push event says was created, changed or deleted, when the event's `type` - the published model class name (`ContactMongo`,
|
|
9
|
+
* `TaskSQL`, ...) - matches `model`, or `undefined` for anything else the shared push connection delivers. Only the uid is taken: the payload
|
|
10
|
+
* is whatever every subscriber of the folder may see (a private calendar event's busy block), so a page fetches the item again by its uid.
|
|
11
|
+
*/
|
|
12
|
+
export function changedItemOf(event, model) {
|
|
13
|
+
if (!model.test(event.type) || (event.action !== "create" && event.action !== "update" && event.action !== "delete")) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const data = event.data;
|
|
17
|
+
return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Keeps a page's folders live: while the page is mounted the tab's push connection is subscribed to `folderUids` (under the page's own
|
|
21
|
+
* `group` - see `PushClient.setChannels()`; Mail's connection subscribes to mail folders only, so nothing else asks for a calendar,
|
|
22
|
+
* contacts or tasks folder), and `onChange` is called with every item `parse` recognizes - created, changed or deleted anywhere: another
|
|
23
|
+
* tab, a phone over ActiveSync, the server itself. `onChange` may change between renders; the latest is called.
|
|
24
|
+
*/
|
|
25
|
+
export function useFolderLiveUpdates(group, folderUids, parse, onChange) {
|
|
26
|
+
const key = [...new Set(folderUids)].sort().join(",");
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const client = getPushClient();
|
|
29
|
+
client.setChannels(key ? key.split(",") : [], group);
|
|
30
|
+
client.start();
|
|
31
|
+
return () => client.setChannels([], group);
|
|
32
|
+
}, [group, key]);
|
|
33
|
+
const latest = useRef({ parse, onChange });
|
|
34
|
+
latest.current = { parse, onChange };
|
|
35
|
+
useEffect(() => getPushClient().onEvent((event) => {
|
|
36
|
+
const change = latest.current.parse(event);
|
|
37
|
+
if (change) {
|
|
38
|
+
latest.current.onChange(change);
|
|
39
|
+
}
|
|
40
|
+
}), []);
|
|
41
|
+
}
|
|
@@ -32,4 +32,4 @@ export declare function useUnlockEpoch(): number;
|
|
|
32
32
|
* Decrypts one message to recover its subject and preview, when its mailbox's keys are unlocked and it isn't already known. Best effort: a
|
|
33
33
|
* message that can't be fetched or opened is simply left as it was. Never prompts.
|
|
34
34
|
*/
|
|
35
|
-
export declare function decryptForDisplay(uid: string, mailboxUid: string): Promise<void>;
|
|
35
|
+
export declare function decryptForDisplay(uid: string, mailboxUid: string, sealedSummary?: string): Promise<void>;
|
|
@@ -113,16 +113,24 @@ export function useUnlockEpoch() {
|
|
|
113
113
|
* Decrypts one message to recover its subject and preview, when its mailbox's keys are unlocked and it isn't already known. Best effort: a
|
|
114
114
|
* message that can't be fetched or opened is simply left as it was. Never prompts.
|
|
115
115
|
*/
|
|
116
|
-
export async function decryptForDisplay(uid, mailboxUid) {
|
|
116
|
+
export async function decryptForDisplay(uid, mailboxUid, sealedSummary) {
|
|
117
117
|
const unlocked = getUnlockedKeys(mailboxUid);
|
|
118
118
|
if (!unlocked || entries[uid] || inFlight.has(uid) || decryptFailedBefore(uid)) {
|
|
119
119
|
return;
|
|
120
120
|
}
|
|
121
121
|
inFlight.add(uid);
|
|
122
122
|
try {
|
|
123
|
+
const securityApi = await import("../../../lib/crypto/messageSecurity.js");
|
|
124
|
+
// A message sealed at rest has its subject and preview sealed on its row: opening that small summary saves downloading it.
|
|
125
|
+
const summary = sealedSummary ? await securityApi.openZeroAccessSummary(sealedSummary, unlocked.zeroAccessKeys) : undefined;
|
|
126
|
+
if (summary) {
|
|
127
|
+
if (getUnlockedKeys(mailboxUid) === unlocked) {
|
|
128
|
+
rememberDecrypted(uid, mailboxUid, { subject: summary.subject, preview: summary.preview.slice(0, PREVIEW_MAX_LENGTH) });
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
123
132
|
const rawMime = await getMessageRawContent(uid);
|
|
124
|
-
const
|
|
125
|
-
const security = await evaluateMessageSecurity(rawMime, unlocked);
|
|
133
|
+
const security = await securityApi.evaluateMessageSecurity(rawMime, unlocked);
|
|
126
134
|
// A lock while this ran: the result must not go back on screen.
|
|
127
135
|
if (getUnlockedKeys(mailboxUid) === unlocked) {
|
|
128
136
|
rememberDecrypted(uid, mailboxUid, { subject: security.subject, preview: security.html ? previewOf(security.html) : security.text?.slice(0, PREVIEW_MAX_LENGTH) });
|
|
@@ -106,7 +106,7 @@ async function completeContext(request, client) {
|
|
|
106
106
|
async function assemble(request, mailbox, mode, statuses, withSignature, client) {
|
|
107
107
|
const { draft, to, cc, bcc, subject, html } = request;
|
|
108
108
|
if (mode === "plain") {
|
|
109
|
-
return assembleDraft(draft.uid, { to, cc, bcc, subject, html }, client);
|
|
109
|
+
return assembleDraft(draft.uid, { to, cc, bcc, subject, html, intent: "send" }, client);
|
|
110
110
|
}
|
|
111
111
|
const keys = readSendKeys(request.mailboxUid, request.signEnabled, mailbox);
|
|
112
112
|
const own = mailbox;
|
|
@@ -131,7 +131,7 @@ async function assemble(request, mailbox, mode, statuses, withSignature, client)
|
|
|
131
131
|
? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, [keys.unlocked.encryptionCertDer, ...statuses.map((status) => fromBase64(status.encryptCert.publicKey))], signing)
|
|
132
132
|
: await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing.certDer, signing.privateKey);
|
|
133
133
|
const rawMime = assembleOutboundMime(outerHeaders, mimePart);
|
|
134
|
-
return assembleDraftRaw(draft.uid, { to, cc, bcc, subject: outerHeaders.subject, rawMime }, client);
|
|
134
|
+
return assembleDraftRaw(draft.uid, { to, cc, bcc, subject: outerHeaders.subject, rawMime, intent: "send" }, client);
|
|
135
135
|
}
|
|
136
136
|
function failureOf(err, fallback) {
|
|
137
137
|
const failure = describeSendFailure(err, fallback);
|
|
@@ -57,9 +57,7 @@ async function openFailedDraft(event, client) {
|
|
|
57
57
|
});
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
// this task's files); it still falls back to the default global session even under an ApiClientContext.Provider.
|
|
62
|
-
const original = await loadOriginalMessage(moved, null);
|
|
60
|
+
const original = await loadOriginalMessage(moved, null, {}, client);
|
|
63
61
|
const attachments = await listAttachments(moved.folderUid, moved.uid, client).catch(() => []);
|
|
64
62
|
const byType = (type) => moved.recipients.filter((recipient) => recipient.type === type).map(formatRecipient).join(", ");
|
|
65
63
|
openComposeFromOutside({
|
|
@@ -42,6 +42,8 @@ export function reportNotice(result, subject, alwaysTrust) {
|
|
|
42
42
|
? "The spam filter could not be trained from this report."
|
|
43
43
|
: result.learnSkipped === "encrypted"
|
|
44
44
|
? "Not used to train the spam filter: the message is encrypted."
|
|
45
|
-
:
|
|
45
|
+
: result.learnSkipped === "zero_access"
|
|
46
|
+
? "Not used to train the spam filter: unlock your mailbox to let it learn from protected mail."
|
|
47
|
+
: undefined;
|
|
46
48
|
return { title: kind.title, message: parts.join(" "), hint };
|
|
47
49
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Message } from "../../../lib/mail/mailApi.js";
|
|
2
|
+
import type { ApiClient } from "../../../lib/util/api.js";
|
|
3
|
+
/**
|
|
4
|
+
* The original source of a zero-access `message`, decrypted with its mailbox's unlocked keys and base64-encoded - what the server, which holds
|
|
5
|
+
* only the sealed copy, needs for an action that reads the message itself (`reportMessage()`'s `rawMessage`). `undefined` for any other
|
|
6
|
+
* message, while the mailbox is locked, or when it can't be fetched or opened: the action then goes ahead without it.
|
|
7
|
+
*/
|
|
8
|
+
export declare function zeroAccessSourceBase64(message: Pick<Message, "uid" | "mailboxUid" | "zeroAccess">, client?: ApiClient): Promise<string | undefined>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { getUnlockedKeys } from "../../../lib/crypto/keySession.js";
|
|
6
|
+
import { openZeroAccessMessage } from "../../../lib/crypto/messageSecurity.js";
|
|
7
|
+
import { getMessageRawContent } from "../../../lib/mail/mailApi.js";
|
|
8
|
+
/**
|
|
9
|
+
* The original source of a zero-access `message`, decrypted with its mailbox's unlocked keys and base64-encoded - what the server, which holds
|
|
10
|
+
* only the sealed copy, needs for an action that reads the message itself (`reportMessage()`'s `rawMessage`). `undefined` for any other
|
|
11
|
+
* message, while the mailbox is locked, or when it can't be fetched or opened: the action then goes ahead without it.
|
|
12
|
+
*/
|
|
13
|
+
export async function zeroAccessSourceBase64(message, client) {
|
|
14
|
+
const unlocked = message.zeroAccess ? getUnlockedKeys(message.mailboxUid) : undefined;
|
|
15
|
+
if (!unlocked) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const original = await openZeroAccessMessage(await getMessageRawContent(message.uid, client), unlocked);
|
|
20
|
+
// A binary string (one char per byte), which `btoa()` encodes as the bytes themselves.
|
|
21
|
+
return original === undefined ? undefined : btoa(original);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { PublicKey } from "../../../lib/crypto/keyvaultApi.js";
|
|
2
2
|
import type { Folder } from "../../../lib/mail/mailApi.js";
|
|
3
3
|
export declare const POLL_INTERVAL_MS = 5000;
|
|
4
|
+
/** How often an unlocked zero-access mailbox's local index is brought up to date with the server's sealed index. */
|
|
5
|
+
export declare const ZERO_ACCESS_SYNC_INTERVAL_MS = 120000;
|
|
4
6
|
export interface LocalIndexLifecycleProps {
|
|
5
7
|
mailboxUid?: string;
|
|
6
8
|
mailboxKeys?: PublicKey[];
|
|
@@ -34,8 +34,16 @@ const loadBuilder = () => (builder ?? (builder = import("./localIndexBuilder.js"
|
|
|
34
34
|
builder = undefined;
|
|
35
35
|
throw err;
|
|
36
36
|
})));
|
|
37
|
+
/** The zero-access index sync (`zeroAccessIndexSync.ts`), loaded the same way: only once a zero-access mailbox is unlocked. */
|
|
38
|
+
let syncer;
|
|
39
|
+
const loadSyncer = () => (syncer ?? (syncer = import("./zeroAccessIndexSync.js").catch((err) => {
|
|
40
|
+
syncer = undefined;
|
|
41
|
+
throw err;
|
|
42
|
+
})));
|
|
37
43
|
import { destroyLocalIndex, pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
|
|
38
44
|
export const POLL_INTERVAL_MS = 5000;
|
|
45
|
+
/** How often an unlocked zero-access mailbox's local index is brought up to date with the server's sealed index. */
|
|
46
|
+
export const ZERO_ACCESS_SYNC_INTERVAL_MS = 120000;
|
|
39
47
|
/** Renders nothing - pure side-effect component, mounted by `MailShell.tsx`. */
|
|
40
48
|
export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMailboxUids }) {
|
|
41
49
|
// The explicit ApiClient (from useApiClient()) this component is rendered under - undefined for every existing caller (the SSR web/admin
|
|
@@ -49,6 +57,23 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
|
|
|
49
57
|
// The latest props (and client), for the long-lived polling interval below.
|
|
50
58
|
const latestRef = useRef({ mailboxUid, folders, client });
|
|
51
59
|
latestRef.current = { mailboxUid, folders, client };
|
|
60
|
+
// mailboxUid -> when its last zero-access sync started. Set only once its first build finished (the sync writes into the index the
|
|
61
|
+
// build opened); a mailbox in `syncingRef` has a pass running, and never gets a second one alongside it.
|
|
62
|
+
const lastSyncRef = useRef(new Map());
|
|
63
|
+
const syncingRef = useRef(new Set());
|
|
64
|
+
function syncIfDue(targetMailboxUid, targetClient, first = false) {
|
|
65
|
+
const unlocked = getUnlockedKeys(targetMailboxUid);
|
|
66
|
+
const last = lastSyncRef.current.get(targetMailboxUid);
|
|
67
|
+
if (!unlocked?.zeroAccessKeys?.length || syncingRef.current.has(targetMailboxUid) || (!first && (last === undefined || Date.now() - last < ZERO_ACCESS_SYNC_INTERVAL_MS))) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
lastSyncRef.current.set(targetMailboxUid, Date.now());
|
|
71
|
+
syncingRef.current.add(targetMailboxUid);
|
|
72
|
+
loadSyncer()
|
|
73
|
+
.then(({ syncZeroAccessIndex }) => syncZeroAccessIndex(targetMailboxUid, unlocked, targetClient))
|
|
74
|
+
.catch(() => undefined)
|
|
75
|
+
.finally(() => syncingRef.current.delete(targetMailboxUid));
|
|
76
|
+
}
|
|
52
77
|
function startBuildIfUnlocked(targetMailboxUid, targetFolders, targetClient) {
|
|
53
78
|
const unlocked = getUnlockedKeys(targetMailboxUid);
|
|
54
79
|
if (!unlocked || targetFolders.length === 0 || trackedRef.current.get(targetMailboxUid)) {
|
|
@@ -59,6 +84,7 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
|
|
|
59
84
|
// the rest of the app depends on.
|
|
60
85
|
loadBuilder()
|
|
61
86
|
.then(({ buildLocalIndex }) => buildLocalIndex(targetMailboxUid, unlocked, targetFolders, undefined, targetClient))
|
|
87
|
+
.then(() => syncIfDue(targetMailboxUid, targetClient, true))
|
|
62
88
|
.catch(() => undefined);
|
|
63
89
|
}
|
|
64
90
|
useEffect(() => {
|
|
@@ -72,6 +98,7 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
|
|
|
72
98
|
if (wasUnlocked && !getUnlockedKeys(trackedUid)) {
|
|
73
99
|
// A present-to-absent transition: something just destroyed this mailbox's keys.
|
|
74
100
|
trackedRef.current.set(trackedUid, false);
|
|
101
|
+
lastSyncRef.current.delete(trackedUid);
|
|
75
102
|
// Stop the pass still running with the now-destroyed keys; the destroy itself also makes
|
|
76
103
|
// the Worker reject anything that pass still sends.
|
|
77
104
|
loadBuilder()
|
|
@@ -84,6 +111,7 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
|
|
|
84
111
|
const { mailboxUid: activeUid, folders: activeFolders, client: activeClient } = latestRef.current;
|
|
85
112
|
if (activeUid) {
|
|
86
113
|
startBuildIfUnlocked(activeUid, activeFolders, activeClient);
|
|
114
|
+
syncIfDue(activeUid, activeClient);
|
|
87
115
|
}
|
|
88
116
|
}, POLL_INTERVAL_MS);
|
|
89
117
|
return () => clearInterval(interval);
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
* fully searchable via Tier 1; indexing it here too would spend this index's bounded byte budget on
|
|
21
21
|
* content that didn't need it.
|
|
22
22
|
*
|
|
23
|
+
* **Shared with the mailbox's other devices (Source C).** In a mailbox with a zero-access key, a pass first imports the end-to-end mail
|
|
24
|
+
* other devices already decrypted (`importClientSegments()`), at each message's version, so it isn't decrypted again here; and what this
|
|
25
|
+
* pass decrypts it uploads for them, sealed to the zero-access key (`uploadClientDocuments()`). Both are best effort - see
|
|
26
|
+
* `zeroAccessIndexSync.ts`.
|
|
27
|
+
*
|
|
23
28
|
* **Verification seals.** A message this pass decrypts that has no seal for the vault's current `masterKeyGeneration` is
|
|
24
29
|
* evaluated with `evaluateMessageSecurityWithSeal()`, against the sender's pinned signing keys from the reader's
|
|
25
30
|
* contacts (`pinnedSigners.ts`, the message pane's own source, looked up once per sender per pass), so a verified
|