@rapidmx/web-client 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/admin/branding/index.tsx +39 -404
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/index.tsx +100 -82
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
- package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +40 -4
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactForm.tsx +38 -4
- package/apps/shared/components/layout/AppShell.tsx +198 -169
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
- package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
- package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +179 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +116 -0
- package/apps/shared/search/localIndexSchema.ts +227 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexVFS.ts +235 -0
- package/apps/shared/search/localIndexWorker.ts +464 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +31 -21
- package/apps/www/contacts/index.tsx +33 -6
- package/apps/www/index.tsx +661 -106
- package/apps/www/messages/[uid].tsx +5 -1
- package/apps/www/settings/encryption/index.tsx +63 -1
- package/apps/www/settings/sharing/index.tsx +271 -0
- package/apps/www/tasks/index.tsx +53 -4
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/index.js +14 -1
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +16 -4
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
- package/dist/apps/shared/components/layout/AppShell.js +30 -7
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
- package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +153 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
- package/dist/apps/shared/search/localIndexSchema.js +179 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexVFS.js +230 -0
- package/dist/apps/shared/search/localIndexWorker.js +341 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +14 -12
- package/dist/apps/www/contacts/index.js +15 -6
- package/dist/apps/www/index.js +496 -94
- package/dist/apps/www/messages/[uid].js +5 -1
- package/dist/apps/www/settings/encryption/index.js +30 -2
- package/dist/apps/www/settings/sharing/index.js +128 -0
- package/dist/apps/www/tasks/index.js +27 -5
- package/package.json +3 -2
|
@@ -28,6 +28,7 @@ export const SETTINGS_SECTIONS: SettingsSectionDef[] = [
|
|
|
28
28
|
{ id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
|
|
29
29
|
{ id: "booking-types", href: "/settings/booking-types", label: "Booking Links" },
|
|
30
30
|
{ id: "encryption", href: "/settings/encryption", label: "Encryption" },
|
|
31
|
+
{ id: "sharing", href: "/settings/sharing", label: "Sharing" },
|
|
31
32
|
{ id: "privacy", href: "/settings/privacy", label: "Privacy & Data" },
|
|
32
33
|
];
|
|
33
34
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { FolderType, listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
6
|
+
|
|
7
|
+
/** The uid of a mailbox's well-known folder of `type` (e.g. its Contacts or Tasks folder), for creating an
|
|
8
|
+
* item in a mailbox other than the one an app currently has loaded. Resolves `undefined` if the mailbox has
|
|
9
|
+
* no such folder; rejects if the folder list can't be fetched. */
|
|
10
|
+
export async function findWellKnownFolderUid(mailboxUid: string, type: FolderType): Promise<string | undefined> {
|
|
11
|
+
const folders = await listFolders(mailboxUid);
|
|
12
|
+
return folders.find((folder) => folder.type === type)?.uid;
|
|
13
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Mounts the Tier 2 local index's lifecycle for one mailbox: starts the background build once it's
|
|
7
|
+
* unlocked, and destroys the index on the same events that destroy the unlocked keys themselves (spec
|
|
8
|
+
* §11 "MUST be destroyed on the same events that destroy private keys: explicit logout, session
|
|
9
|
+
* revocation, and the configurable idle timeout").
|
|
10
|
+
*
|
|
11
|
+
* **Why polling, not hooking the existing destroy call sites directly**: `destroyUnlockedKeys()`
|
|
12
|
+
* (`react-shared/src/crypto/keySession.ts`) has no observer/callback hook, and its own two real call
|
|
13
|
+
* sites are `useIdleKeyTimeout.ts` (in `react-shared`, not this repo) and the manual "Destroy keys now"
|
|
14
|
+
* button in Settings - editing the former would mean a react-shared source change that (per this
|
|
15
|
+
* session's own earlier finding) doesn't reach `web-client` without a package republish + patch refresh
|
|
16
|
+
* cycle, which is out of scope for wiring up a destroy signal. Watching `getUnlockedKeys()` for a
|
|
17
|
+
* present-to-absent transition instead catches *every* destroy path uniformly - idle timeout, the manual
|
|
18
|
+
* button, or anything else that calls it - without needing to know which one fired.
|
|
19
|
+
*
|
|
20
|
+
* **Explicit logout is the one gap this polling doesn't reliably cover**: `AppShell.tsx`'s sign-out
|
|
21
|
+
* navigates away immediately, which may not leave time for a poll tick to fire first. That case is
|
|
22
|
+
* handled separately, directly in `AppShell.tsx`, via `destroyAllLocalIndexes()` - see that call site's
|
|
23
|
+
* own comment.
|
|
24
|
+
*/
|
|
25
|
+
import { useEffect, useRef } from "react";
|
|
26
|
+
import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
27
|
+
import type { PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
28
|
+
import type { Folder } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
29
|
+
import { buildLocalIndex } from "./localIndexBuilder.js";
|
|
30
|
+
import { destroyLocalIndex } from "./localIndexRpcClient.js";
|
|
31
|
+
|
|
32
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
33
|
+
|
|
34
|
+
export interface LocalIndexLifecycleProps {
|
|
35
|
+
mailboxUid?: string;
|
|
36
|
+
mailboxKeys?: PublicKey[];
|
|
37
|
+
folders: Folder[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Renders nothing - pure side-effect component, mounted by `MailShell.tsx` (which is where a
|
|
41
|
+
* `mailboxUid` is actually known; `AppShell.tsx` itself is mailbox-agnostic, shared by every app). Each
|
|
42
|
+
* `buildLocalIndex()` call below omits its `windowConfig` argument deliberately - that leaves the byte
|
|
43
|
+
* budget to its own default, which already resolves per-device (Web vs. Electron) and per-user
|
|
44
|
+
* preference (Settings > Encryption) on its own; see `localIndexBuilder.ts`/`localIndexSizePreference.ts`
|
|
45
|
+
* for how. */
|
|
46
|
+
export default function LocalIndexLifecycle({ mailboxUid, folders }: LocalIndexLifecycleProps) {
|
|
47
|
+
// Guards against re-triggering a build every time this component re-renders (e.g. on an unrelated
|
|
48
|
+
// folders-list refresh) for a mailbox already built/building this session.
|
|
49
|
+
const buildStartedForRef = useRef<string | undefined>(undefined);
|
|
50
|
+
const wasUnlockedRef = useRef(false);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!mailboxUid || folders.length === 0) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const unlocked = getUnlockedKeys(mailboxUid);
|
|
57
|
+
wasUnlockedRef.current = !!unlocked;
|
|
58
|
+
if (unlocked && buildStartedForRef.current !== mailboxUid) {
|
|
59
|
+
buildStartedForRef.current = mailboxUid;
|
|
60
|
+
// Never an unhandled rejection - a broken local index (Worker/WASM/OPFS unsupported or
|
|
61
|
+
// unavailable, a corrupted store) is best-effort infrastructure, not a build the rest of the
|
|
62
|
+
// app depends on. searchTier2.ts's own callers already degrade gracefully independent of
|
|
63
|
+
// whether a build ever completed at all.
|
|
64
|
+
buildLocalIndex(mailboxUid, unlocked, folders).catch(() => undefined);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const interval = setInterval(() => {
|
|
68
|
+
const stillUnlocked = !!getUnlockedKeys(mailboxUid);
|
|
69
|
+
if (wasUnlockedRef.current && !stillUnlocked) {
|
|
70
|
+
// A present-to-absent transition: something just destroyed this mailbox's unlocked keys
|
|
71
|
+
// (idle timeout or the manual "Destroy keys now" button - see this module's own doc
|
|
72
|
+
// comment on why this is observed rather than hooked directly).
|
|
73
|
+
void destroyLocalIndex(mailboxUid);
|
|
74
|
+
buildStartedForRef.current = undefined;
|
|
75
|
+
} else if (!wasUnlockedRef.current && stillUnlocked && buildStartedForRef.current !== mailboxUid) {
|
|
76
|
+
// The mailbox was re-unlocked this session (e.g. via ComposeWindow's or
|
|
77
|
+
// MessageDetailPane's own on-demand unlock prompt) - start the build it missed the first
|
|
78
|
+
// time around.
|
|
79
|
+
buildStartedForRef.current = mailboxUid;
|
|
80
|
+
buildLocalIndex(mailboxUid, getUnlockedKeys(mailboxUid)!, folders).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
wasUnlockedRef.current = stillUnlocked;
|
|
83
|
+
}, POLL_INTERVAL_MS);
|
|
84
|
+
|
|
85
|
+
return () => clearInterval(interval);
|
|
86
|
+
}, [mailboxUid, folders]);
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The actual AES-256-GCM block cipher `localIndexVFS.ts`'s `EncryptingVFS` uses to encrypt/decrypt one
|
|
7
|
+
* physical storage block - deliberately split out from that file so it can be exercised with real
|
|
8
|
+
* WebCrypto in a plain unit test, with no OPFS/Worker/wa-sqlite involved at all (none of those are
|
|
9
|
+
* available under Vitest's jsdom environment, matching the same "node" test environment convention
|
|
10
|
+
* `react-shared`'s other crypto modules already use for exactly this reason).
|
|
11
|
+
*
|
|
12
|
+
* See `localIndexVFS.ts`'s own doc comment for the full design rationale (page layout, why a fresh
|
|
13
|
+
* random nonce per write, why AAD binds to filename+block index). This module is pure mechanism; that
|
|
14
|
+
* one is where the design is explained.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The logical SQLite page size `EncryptingVFS` is designed for - MUST match the `PRAGMA page_size` the
|
|
18
|
+
* database was created with (`localIndexWorker.ts` sets this before the first write). */
|
|
19
|
+
export const LOGICAL_BLOCK_SIZE = 4096;
|
|
20
|
+
const NONCE_LENGTH = 12;
|
|
21
|
+
const TAG_LENGTH = 16;
|
|
22
|
+
/** One physical block on disk: `nonce || ciphertext || tag`. */
|
|
23
|
+
export const PHYSICAL_BLOCK_SIZE = LOGICAL_BLOCK_SIZE + NONCE_LENGTH + TAG_LENGTH;
|
|
24
|
+
|
|
25
|
+
/** Thrown when a stored block fails AES-GCM authentication - a wrong/rotated key, on-disk corruption, or
|
|
26
|
+
* a block moved to the wrong position (see `localIndexVFS.ts`'s doc comment on why AAD binds position).
|
|
27
|
+
* The worker's caller treats this identically to a schema-version mismatch: discard the whole index and
|
|
28
|
+
* rebuild (spec §11 "Invalidation"). */
|
|
29
|
+
export class PageCorruptedError extends Error {
|
|
30
|
+
/** The underlying `DOMException`/error `crypto.subtle.decrypt()` threw, or an inner-VFS read failure
|
|
31
|
+
* - kept as a plain field rather than the ES2022 `Error` constructor's `cause` option, since
|
|
32
|
+
* web-client's `tsconfig.json` targets ES2020. */
|
|
33
|
+
readonly cause: unknown;
|
|
34
|
+
|
|
35
|
+
constructor(filename: string, blockIndex: number, cause: unknown) {
|
|
36
|
+
super(`Local search index block ${blockIndex} of '${filename}' failed to decrypt.`);
|
|
37
|
+
this.cause = cause;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function importAesGcmKey(rawKey: Uint8Array): Promise<CryptoKey> {
|
|
42
|
+
return crypto.subtle.importKey("raw", rawKey as BufferSource, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildBlockAad(filename: string, blockIndex: number): Uint8Array {
|
|
46
|
+
return new TextEncoder().encode(`rapidmx-local-index-block:${filename}:${blockIndex}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Encrypts one logical block (`plaintext`, exactly `LOGICAL_BLOCK_SIZE` bytes) into its physical,
|
|
50
|
+
* on-disk representation (`PHYSICAL_BLOCK_SIZE` bytes: a fresh random nonce followed by
|
|
51
|
+
* ciphertext+tag). */
|
|
52
|
+
export async function encryptBlock(key: CryptoKey, filename: string, blockIndex: number, plaintext: Uint8Array): Promise<Uint8Array> {
|
|
53
|
+
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
|
|
54
|
+
const ciphertextAndTag = new Uint8Array(
|
|
55
|
+
await crypto.subtle.encrypt(
|
|
56
|
+
{ name: "AES-GCM", iv: nonce as BufferSource, additionalData: buildBlockAad(filename, blockIndex) as BufferSource },
|
|
57
|
+
key,
|
|
58
|
+
plaintext as BufferSource,
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
const physical = new Uint8Array(PHYSICAL_BLOCK_SIZE);
|
|
62
|
+
physical.set(nonce, 0);
|
|
63
|
+
physical.set(ciphertextAndTag, NONCE_LENGTH);
|
|
64
|
+
return physical;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Inverse of `encryptBlock()`. Throws `PageCorruptedError` (never a raw `DOMException`) on auth
|
|
68
|
+
* failure - a wrong key, tampered ciphertext, or a block swapped into the wrong position (caught by the
|
|
69
|
+
* position-bound AAD not matching). */
|
|
70
|
+
export async function decryptBlock(key: CryptoKey, filename: string, blockIndex: number, physical: Uint8Array): Promise<Uint8Array> {
|
|
71
|
+
const nonce = physical.subarray(0, NONCE_LENGTH);
|
|
72
|
+
const ciphertextAndTag = physical.subarray(NONCE_LENGTH, PHYSICAL_BLOCK_SIZE);
|
|
73
|
+
try {
|
|
74
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
75
|
+
{ name: "AES-GCM", iv: nonce as BufferSource, additionalData: buildBlockAad(filename, blockIndex) as BufferSource },
|
|
76
|
+
key,
|
|
77
|
+
ciphertextAndTag as BufferSource,
|
|
78
|
+
);
|
|
79
|
+
return new Uint8Array(plaintext);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new PageCorruptedError(filename, blockIndex, err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The Tier 2 local index's background builder (`specs/search.md` §11 "Initial build... incrementally,
|
|
7
|
+
* newest-first"). Runs on the main thread - the actual storage I/O and encryption happen in
|
|
8
|
+
* `localIndexWorker.ts` (off the UI thread, per that file's own doc comment); this module's own work is
|
|
9
|
+
* orchestration (deciding what to fetch) plus already-async fetch/decrypt calls, not CPU-heavy work that
|
|
10
|
+
* would itself need to move off-thread.
|
|
11
|
+
*
|
|
12
|
+
* **Known scoping simplification**: walks the mailbox's real mail folders one at a time (a fixed,
|
|
13
|
+
* reasonable priority order - Inbox and Sent first), each already newest-first via `listMessages()`'s own
|
|
14
|
+
* default sort, rather than a true interleaved k-way merge producing one single globally-newest-first
|
|
15
|
+
* stream across folders. A message in, say, Sent slightly older than the *oldest-processed-so-far*
|
|
16
|
+
* message in Inbox can therefore be indexed slightly out of true global date order relative to it. Given
|
|
17
|
+
* the byte-budget eviction below is itself date-based (oldest `date_for_sort` first, enforced by
|
|
18
|
+
* `localIndexWorker.ts`'s own `applyEviction()` after every insert - see that file), a small amount of
|
|
19
|
+
* cross-folder interleaving imprecision here does not affect *what* ultimately survives the window, only
|
|
20
|
+
* the exact order entities are inserted (and therefore briefly evicted-and-reinserted) in.
|
|
21
|
+
*
|
|
22
|
+
* Only encrypted messages (`subject === "[...]"`) are indexed - see `ENCRYPTED_SUBJECT_PLACEHOLDER`'s
|
|
23
|
+
* own precedent in `apps/www/index.tsx`'s inbox-list decrypt work. An unencrypted message is already
|
|
24
|
+
* fully searchable via Tier 1; indexing it here too would spend this index's bounded byte budget on
|
|
25
|
+
* content that didn't need it.
|
|
26
|
+
*/
|
|
27
|
+
import { getMessageRawContent, listMessages, type Folder, type Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
28
|
+
import { evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
|
|
29
|
+
import type { UnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
30
|
+
import type { LocalIndexEntity } from "./localIndexSchema.js";
|
|
31
|
+
import { indexLocalEntities, initLocalIndex, setLocalIndexBuilding, setLocalIndexWindow } from "./localIndexRpcClient.js";
|
|
32
|
+
import { deriveLocalIndexKey } from "./localIndexKey.js";
|
|
33
|
+
import { getLocalIndexByteBudget } from "./localIndexSizePreference.js";
|
|
34
|
+
|
|
35
|
+
/** The RFC 9788 placeholder subject every encrypted message's outer envelope carries server-side - see
|
|
36
|
+
* `apps/www/index.tsx`'s identical constant and its own doc comment for the full citation. */
|
|
37
|
+
const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
|
|
38
|
+
|
|
39
|
+
/** §11's Window Sizing table's time-floor column - explicitly unvalidated starting-point default per the
|
|
40
|
+
* spec's own §16 "Measurement Task" ("cannot be supplied by design work... MUST NOT be treated as
|
|
41
|
+
* validated"). Used as-is rather than invented/adjusted here. Not user-adjustable today (unlike the byte
|
|
42
|
+
* budget - see `localIndexSizePreference.ts`) - nothing in this codebase has asked for that yet. */
|
|
43
|
+
export const WEB_TIME_FLOOR_MONTHS = 12;
|
|
44
|
+
|
|
45
|
+
/** Either bound as `0` means "no limit": `applyEviction()` (`localIndexWorker.ts`) already treats a
|
|
46
|
+
* falsy byte budget as unconfigured/unenforced, and `buildLocalIndex()` below mirrors that same
|
|
47
|
+
* convention for `timeFloorMonths` so a single `0` means the same thing in both dimensions. */
|
|
48
|
+
export interface LocalIndexWindowConfig {
|
|
49
|
+
timeFloorMonths: number;
|
|
50
|
+
byteBudgetBytes: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Folder types that actually hold messages - excludes `calendar`/`contacts`/other non-mail folder types
|
|
54
|
+
* `FolderType` also covers. Inbox and Sent first: the two folders a "did I find that email" search is
|
|
55
|
+
* overwhelmingly likely to land in, so they're covered soonest if the build is interrupted (tab closed,
|
|
56
|
+
* idle timeout) partway through. */
|
|
57
|
+
const MESSAGE_FOLDER_TYPES = new Set(["inbox", "sent_items", "drafts", "deleted_items", "outbox", "junk"]);
|
|
58
|
+
const FOLDER_PRIORITY: Record<string, number> = { inbox: 0, sent_items: 1 };
|
|
59
|
+
|
|
60
|
+
const PAGE_SIZE = 100;
|
|
61
|
+
const MAX_APPROX_BYTES_PER_MESSAGE_PADDING = 512; // subject/participants/flags overhead beyond raw text length
|
|
62
|
+
|
|
63
|
+
/** `cutoff === undefined` means no time floor at all (an unbounded `windowConfig`) - every message is
|
|
64
|
+
* "within" it, so the caller's own end-of-folder check (`messages.length < PAGE_SIZE`) becomes the only
|
|
65
|
+
* stopping condition. */
|
|
66
|
+
function isWithinTimeFloor(receivedDate: string, cutoff: Date | undefined): boolean {
|
|
67
|
+
return !cutoff || new Date(receivedDate).getTime() >= cutoff.getTime();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function estimateByteSize(entity: Pick<LocalIndexEntity, "subject" | "body" | "attachmentText" | "participants">): number {
|
|
71
|
+
const textLength =
|
|
72
|
+
(entity.subject?.length ?? 0) + (entity.body?.length ?? 0) + (entity.attachmentText?.length ?? 0) + entity.participants.length;
|
|
73
|
+
return textLength + MAX_APPROX_BYTES_PER_MESSAGE_PADDING;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Decrypts one message and shapes it into a `LocalIndexEntity`, or `undefined` when nothing usable was
|
|
77
|
+
* recovered (a decrypt failure, or a message that turns out not to actually be encrypted despite the
|
|
78
|
+
* placeholder subject) - mirrors `searchTier3.ts`'s own `!security.html && !security.subject` discard
|
|
79
|
+
* rule exactly, for the same reason. */
|
|
80
|
+
async function buildEntity(message: Message, unlocked: UnlockedKeys): Promise<LocalIndexEntity | undefined> {
|
|
81
|
+
try {
|
|
82
|
+
const rawMime = await getMessageRawContent(message.uid);
|
|
83
|
+
const security = await evaluateMessageSecurity(rawMime, unlocked);
|
|
84
|
+
if (!security.subject && !security.html) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const participants = [message.from.address, message.from.displayName, ...message.recipients.map((r) => r.address)]
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.join(" ");
|
|
90
|
+
const setFlags = Object.entries(message.flags)
|
|
91
|
+
.filter(([, value]) => value)
|
|
92
|
+
.map(([key]) => key);
|
|
93
|
+
// Leading/trailing comma so `flags LIKE '%,x,%'` (localIndexSchema.ts's buildSearchPredicates())
|
|
94
|
+
// matches correctly even for the first/last flag in the list.
|
|
95
|
+
const flags = `,${setFlags.join(",")},`;
|
|
96
|
+
const entity: LocalIndexEntity = {
|
|
97
|
+
entityType: "message",
|
|
98
|
+
entityUid: message.uid,
|
|
99
|
+
mailboxUid: message.mailboxUid,
|
|
100
|
+
folderUid: message.folderUid,
|
|
101
|
+
dateForSort: message.receivedDate,
|
|
102
|
+
participants,
|
|
103
|
+
flags,
|
|
104
|
+
hasAttachments: message.hasAttachments,
|
|
105
|
+
subject: security.subject,
|
|
106
|
+
body: security.html,
|
|
107
|
+
byteSize: 0, // filled in below, after the fields above are known
|
|
108
|
+
};
|
|
109
|
+
entity.byteSize = estimateByteSize(entity);
|
|
110
|
+
return entity;
|
|
111
|
+
} catch {
|
|
112
|
+
// Best-effort, matching searchTier3.ts's own Promise.allSettled-per-candidate posture - one
|
|
113
|
+
// message's fetch/decrypt failure never aborts the rest of the build.
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Runs one full incremental build pass for `mailboxUid`: sets the window, walks mail folders newest-first
|
|
119
|
+
* (per this module's own doc comment on the folder-order simplification), decrypts and indexes only
|
|
120
|
+
* `"[...]"`-subject messages until each folder's own coverage passes the time floor, then clears the
|
|
121
|
+
* `building` flag. Never throws - a failure partway through leaves whatever was indexed so far in place
|
|
122
|
+
* (spec §11's "incomplete-index UX... MUST indicate that coverage is partial" is served by `coverage()`
|
|
123
|
+
* truthfully reporting whatever `indexedFrom` this run actually reached, not by this function needing to
|
|
124
|
+
* succeed completely).
|
|
125
|
+
*
|
|
126
|
+
* `windowConfig` defaults to this device's own configured byte budget (`getLocalIndexByteBudget()` -
|
|
127
|
+
* 500 MB in a browser tab, 1 GB in Electron, or whatever the user has since set in Settings > Encryption)
|
|
128
|
+
* alongside the fixed time floor above. Evaluated fresh on every call with no explicit override, so a
|
|
129
|
+
* preference change in Settings takes effect starting with this mailbox's next build pass (its next
|
|
130
|
+
* unlock), without requiring a reload.
|
|
131
|
+
*/
|
|
132
|
+
export async function buildLocalIndex(
|
|
133
|
+
mailboxUid: string,
|
|
134
|
+
unlocked: UnlockedKeys,
|
|
135
|
+
folders: Folder[],
|
|
136
|
+
windowConfig: LocalIndexWindowConfig = { timeFloorMonths: WEB_TIME_FLOOR_MONTHS, byteBudgetBytes: getLocalIndexByteBudget() },
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
const indexKey = await deriveLocalIndexKey(unlocked.masterKey, mailboxUid);
|
|
139
|
+
await initLocalIndex({ mailboxUid, indexKey });
|
|
140
|
+
await setLocalIndexWindow(mailboxUid, windowConfig.timeFloorMonths, windowConfig.byteBudgetBytes);
|
|
141
|
+
await setLocalIndexBuilding(mailboxUid, true);
|
|
142
|
+
try {
|
|
143
|
+
let cutoff: Date | undefined;
|
|
144
|
+
if (windowConfig.timeFloorMonths > 0) {
|
|
145
|
+
cutoff = new Date();
|
|
146
|
+
cutoff.setMonth(cutoff.getMonth() - windowConfig.timeFloorMonths);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const mailFolders = folders
|
|
150
|
+
.filter((f) => MESSAGE_FOLDER_TYPES.has(f.type))
|
|
151
|
+
.sort((a, b) => (FOLDER_PRIORITY[a.type] ?? 99) - (FOLDER_PRIORITY[b.type] ?? 99));
|
|
152
|
+
|
|
153
|
+
for (const folder of mailFolders) {
|
|
154
|
+
let page = 0;
|
|
155
|
+
for (;;) {
|
|
156
|
+
const messages = await listMessages(folder.uid, { page, limit: PAGE_SIZE }).catch(() => []);
|
|
157
|
+
if (messages.length === 0) {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
|
|
161
|
+
if (encrypted.length > 0) {
|
|
162
|
+
const entities = (await Promise.all(encrypted.map((m) => buildEntity(m, unlocked)))).filter(
|
|
163
|
+
(e): e is LocalIndexEntity => e !== undefined,
|
|
164
|
+
);
|
|
165
|
+
if (entities.length > 0) {
|
|
166
|
+
await indexLocalEntities(mailboxUid, entities);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const oldestOnPage = messages[messages.length - 1];
|
|
170
|
+
if (!isWithinTimeFloor(oldestOnPage.receivedDate, cutoff) || messages.length < PAGE_SIZE) {
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
page += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
await setLocalIndexBuilding(mailboxUid, false);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Derives the page-encryption key for one mailbox's Tier 2 local index (`specs/search.md` §11
|
|
7
|
+
* "Persistence and protection... MUST be encrypted at rest under the mailbox master key (MK)... using
|
|
8
|
+
* the same AEAD construction"). Reuses `crypto/masterKey.ts`'s existing `hkdfDerive()` - the same HKDF
|
|
9
|
+
* primitive every other MK-derived key in this codebase already goes through.
|
|
10
|
+
*
|
|
11
|
+
* No persisted per-install salt: MK is already 32 bytes of uniform random key material (see
|
|
12
|
+
* `masterKey.ts`'s own `generateMasterKey()`), so a fixed, non-secret salt is standard HKDF practice
|
|
13
|
+
* here (RFC 5869 - a salt matters for stretching low-entropy input, not for re-randomizing input that's
|
|
14
|
+
* already uniformly random) and avoids needing to persist, transmit, or ever lose a random salt just to
|
|
15
|
+
* re-derive the same key on the next unlock.
|
|
16
|
+
*/
|
|
17
|
+
import { hkdfDerive } from "@rapidmx/react-shared/crypto/masterKey.js";
|
|
18
|
+
|
|
19
|
+
/** Fixed, non-secret HKDF salt - see this module's own doc comment for why a fixed salt is fine here. */
|
|
20
|
+
const FIXED_SALT = new TextEncoder().encode("rapidmx-local-search-index-v1");
|
|
21
|
+
|
|
22
|
+
/** Derives this mailbox's local-index page-encryption key from its already-unlocked master key. Every
|
|
23
|
+
* call with the same `(masterKey, mailboxUid)` pair returns the identical key - callers never need to
|
|
24
|
+
* persist it themselves. */
|
|
25
|
+
export async function deriveLocalIndexKey(masterKey: Uint8Array, mailboxUid: string): Promise<Uint8Array> {
|
|
26
|
+
return hkdfDerive(masterKey, FIXED_SALT, `local-search-index:${mailboxUid}`);
|
|
27
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Main-thread Promise-based RPC wrapper around `localIndexWorker.ts`. One Worker per tab (spawned
|
|
7
|
+
* lazily, on first use, not at module load - a page that never touches search shouldn't pay for booting
|
|
8
|
+
* the WASM module at all), shared across every mailbox `init()`-ed this session - the Worker itself keeps
|
|
9
|
+
* a per-mailbox connection map (see that file's own doc comment).
|
|
10
|
+
*
|
|
11
|
+
* `postMessage` has no native request/response pairing, so every call generates its own numeric `id` and
|
|
12
|
+
* this module correlates the eventual matching response via a pending-request map - the same shape the
|
|
13
|
+
* `__spike__` validation harness used during development, now the real thing.
|
|
14
|
+
*/
|
|
15
|
+
import type { ParsedSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
|
|
16
|
+
import type {
|
|
17
|
+
Coverage,
|
|
18
|
+
IndexEntitiesParams,
|
|
19
|
+
InitParams,
|
|
20
|
+
LocalIndexRequest,
|
|
21
|
+
LocalIndexResponse,
|
|
22
|
+
LocalSearchPage,
|
|
23
|
+
RemoveEntityParams,
|
|
24
|
+
SearchParams,
|
|
25
|
+
SetBuildingParams,
|
|
26
|
+
SetWindowParams,
|
|
27
|
+
} from "./localIndexWorker.js";
|
|
28
|
+
import type { LocalIndexEntity } from "./localIndexSchema.js";
|
|
29
|
+
|
|
30
|
+
let worker: Worker | undefined;
|
|
31
|
+
let nextRequestId = 1;
|
|
32
|
+
const pending = new Map<number, { resolve: (value: unknown) => void; reject: (err: Error) => void }>();
|
|
33
|
+
|
|
34
|
+
/** Every mailboxUid `initLocalIndex()` has opened this session, not yet `destroyLocalIndex()`-ed - lets
|
|
35
|
+
* `destroyAllLocalIndexes()` (called from `AppShell.tsx`'s sign-out, which has no mailboxUid of its own
|
|
36
|
+
* to pass - see that call site's own comment) destroy every open index without needing one threaded
|
|
37
|
+
* through. Mirrors `keySession.ts`'s own module-level `sessions` map in spirit - session-scoped,
|
|
38
|
+
* intentionally never persisted. */
|
|
39
|
+
const initializedMailboxes = new Set<string>();
|
|
40
|
+
|
|
41
|
+
function getWorker(): Worker {
|
|
42
|
+
if (!worker) {
|
|
43
|
+
worker = new Worker(new URL("./localIndexWorker.ts", import.meta.url), { type: "module" });
|
|
44
|
+
worker.addEventListener("message", (event: MessageEvent<LocalIndexResponse>) => {
|
|
45
|
+
const entry = pending.get(event.data.id);
|
|
46
|
+
if (!entry) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
pending.delete(event.data.id);
|
|
50
|
+
if (event.data.ok) {
|
|
51
|
+
entry.resolve(event.data.result);
|
|
52
|
+
} else {
|
|
53
|
+
entry.reject(new Error(event.data.error));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return worker;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function call<T>(method: LocalIndexRequest["method"], params?: unknown): Promise<T> {
|
|
61
|
+
const id = nextRequestId++;
|
|
62
|
+
return new Promise<T>((resolve, reject) => {
|
|
63
|
+
pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
|
|
64
|
+
getWorker().postMessage({ id, method, params } satisfies LocalIndexRequest);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function initLocalIndex(params: InitParams): Promise<void> {
|
|
69
|
+
await call("init", params);
|
|
70
|
+
initializedMailboxes.add(params.mailboxUid);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function indexLocalEntities(mailboxUid: string, entities: LocalIndexEntity[]): Promise<void> {
|
|
74
|
+
return call("indexEntities", { mailboxUid, entities } satisfies IndexEntitiesParams);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function removeLocalEntity(mailboxUid: string, entityUid: string): Promise<void> {
|
|
78
|
+
return call("removeEntity", { mailboxUid, entityUid } satisfies RemoveEntityParams);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function searchLocal(mailboxUid: string, parsed: ParsedSearchQuery, limit: number, offset = 0): Promise<LocalSearchPage> {
|
|
82
|
+
return call("search", { mailboxUid, parsed, limit, offset } satisfies SearchParams);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getLocalCoverage(mailboxUid: string): Promise<Coverage> {
|
|
86
|
+
return call("coverage", mailboxUid);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function setLocalIndexWindow(mailboxUid: string, timeFloorMonths: number, byteBudgetBytes: number): Promise<void> {
|
|
90
|
+
return call("setWindow", { mailboxUid, timeFloorMonths, byteBudgetBytes } satisfies SetWindowParams);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function setLocalIndexBuilding(mailboxUid: string, building: boolean): Promise<void> {
|
|
94
|
+
return call("setBuilding", { mailboxUid, building } satisfies SetBuildingParams);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Destroys one mailbox's local index (both the SQLite connection and its on-disk OPFS storage) - spec
|
|
98
|
+
* §11 "MUST be destroyed on the same events that destroy private keys." Never throws: called from
|
|
99
|
+
* lifecycle hooks (idle timeout, logout, the manual "destroy keys now" button) where a destroy failure
|
|
100
|
+
* shouldn't block the key-destruction it's piggybacking on. */
|
|
101
|
+
export async function destroyLocalIndex(mailboxUid: string): Promise<void> {
|
|
102
|
+
try {
|
|
103
|
+
await call("destroy", mailboxUid);
|
|
104
|
+
} catch {
|
|
105
|
+
// Best-effort - see this function's own doc comment.
|
|
106
|
+
} finally {
|
|
107
|
+
initializedMailboxes.delete(mailboxUid);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Destroys every mailbox's local index this session has opened - for a caller (`AppShell.tsx`'s
|
|
112
|
+
* sign-out) that has no specific mailboxUid of its own, the same "clear everything" shape
|
|
113
|
+
* `destroyUnlockedKeys()` itself offers when called with no argument. */
|
|
114
|
+
export async function destroyAllLocalIndexes(): Promise<void> {
|
|
115
|
+
await Promise.all([...initializedMailboxes].map((mailboxUid) => destroyLocalIndex(mailboxUid)));
|
|
116
|
+
}
|