@rapidmx/web-client 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/apps/admin/branding/index.tsx +39 -404
  2. package/apps/admin/domains/[uid].tsx +92 -259
  3. package/apps/admin/encryption-policy/index.tsx +19 -0
  4. package/apps/admin/index.tsx +100 -82
  5. package/apps/admin/mailbox-policy/index.tsx +19 -0
  6. package/apps/admin/mailboxes/new/index.tsx +28 -291
  7. package/apps/admin/plugins/index.tsx +15 -0
  8. package/apps/admin/retention-policy/index.tsx +39 -132
  9. package/apps/admin/setup/index.tsx +15 -0
  10. package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
  11. package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
  12. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
  13. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
  14. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  15. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
  16. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
  17. package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
  18. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
  19. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
  20. package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
  21. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  22. package/apps/shared/components/calendar/EventModal.tsx +40 -4
  23. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  24. package/apps/shared/components/contacts/ContactForm.tsx +38 -4
  25. package/apps/shared/components/layout/AppShell.tsx +198 -169
  26. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
  27. package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
  28. package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
  29. package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
  30. package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
  31. package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
  32. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  33. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  34. package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
  35. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  36. package/apps/shared/search/localIndexBuilder.ts +179 -0
  37. package/apps/shared/search/localIndexKey.ts +27 -0
  38. package/apps/shared/search/localIndexRpcClient.ts +116 -0
  39. package/apps/shared/search/localIndexSchema.ts +227 -0
  40. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  41. package/apps/shared/search/localIndexVFS.ts +235 -0
  42. package/apps/shared/search/localIndexWorker.ts +464 -0
  43. package/apps/shared/search/searchTier2.ts +79 -0
  44. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  45. package/apps/www/calendar/index.tsx +31 -21
  46. package/apps/www/contacts/index.tsx +33 -6
  47. package/apps/www/index.tsx +661 -106
  48. package/apps/www/messages/[uid].tsx +5 -1
  49. package/apps/www/settings/encryption/index.tsx +63 -1
  50. package/apps/www/settings/sharing/index.tsx +271 -0
  51. package/apps/www/tasks/index.tsx +53 -4
  52. package/dist/apps/admin/branding/index.js +4 -98
  53. package/dist/apps/admin/domains/[uid].js +5 -68
  54. package/dist/apps/admin/encryption-policy/index.js +8 -0
  55. package/dist/apps/admin/index.js +14 -1
  56. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  57. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  58. package/dist/apps/admin/plugins/index.js +6 -0
  59. package/dist/apps/admin/retention-policy/index.js +3 -36
  60. package/dist/apps/admin/setup/index.js +6 -0
  61. package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
  62. package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
  63. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
  64. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
  65. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  66. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
  67. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
  68. package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
  69. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
  70. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
  71. package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
  72. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  73. package/dist/apps/shared/components/calendar/EventModal.js +16 -4
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  75. package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
  76. package/dist/apps/shared/components/layout/AppShell.js +30 -7
  77. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
  78. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
  79. package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
  80. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
  81. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
  83. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  84. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
  86. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  87. package/dist/apps/shared/search/localIndexBuilder.js +153 -0
  88. package/dist/apps/shared/search/localIndexKey.js +25 -0
  89. package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
  90. package/dist/apps/shared/search/localIndexSchema.js +179 -0
  91. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  92. package/dist/apps/shared/search/localIndexVFS.js +230 -0
  93. package/dist/apps/shared/search/localIndexWorker.js +341 -0
  94. package/dist/apps/shared/search/searchTier2.js +38 -0
  95. package/dist/apps/www/calendar/index.js +14 -12
  96. package/dist/apps/www/contacts/index.js +15 -6
  97. package/dist/apps/www/index.js +496 -94
  98. package/dist/apps/www/messages/[uid].js +5 -1
  99. package/dist/apps/www/settings/encryption/index.js +30 -2
  100. package/dist/apps/www/settings/sharing/index.js +128 -0
  101. package/dist/apps/www/tasks/index.js +27 -5
  102. package/package.json +3 -2
@@ -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
+ }