@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
@@ -0,0 +1,12 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
6
+ /** The uid of a mailbox's well-known folder of `type` (e.g. its Contacts or Tasks folder), for creating an
7
+ * item in a mailbox other than the one an app currently has loaded. Resolves `undefined` if the mailbox has
8
+ * no such folder; rejects if the folder list can't be fetched. */
9
+ export async function findWellKnownFolderUid(mailboxUid, type) {
10
+ const folders = await listFolders(mailboxUid);
11
+ return folders.find((folder) => folder.type === type)?.uid;
12
+ }
@@ -0,0 +1,76 @@
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 { buildLocalIndex } from "./localIndexBuilder.js";
28
+ import { destroyLocalIndex } from "./localIndexRpcClient.js";
29
+ const POLL_INTERVAL_MS = 5000;
30
+ /** Renders nothing - pure side-effect component, mounted by `MailShell.tsx` (which is where a
31
+ * `mailboxUid` is actually known; `AppShell.tsx` itself is mailbox-agnostic, shared by every app). Each
32
+ * `buildLocalIndex()` call below omits its `windowConfig` argument deliberately - that leaves the byte
33
+ * budget to its own default, which already resolves per-device (Web vs. Electron) and per-user
34
+ * preference (Settings > Encryption) on its own; see `localIndexBuilder.ts`/`localIndexSizePreference.ts`
35
+ * for how. */
36
+ export default function LocalIndexLifecycle({ mailboxUid, folders }) {
37
+ // Guards against re-triggering a build every time this component re-renders (e.g. on an unrelated
38
+ // folders-list refresh) for a mailbox already built/building this session.
39
+ const buildStartedForRef = useRef(undefined);
40
+ const wasUnlockedRef = useRef(false);
41
+ useEffect(() => {
42
+ if (!mailboxUid || folders.length === 0) {
43
+ return;
44
+ }
45
+ const unlocked = getUnlockedKeys(mailboxUid);
46
+ wasUnlockedRef.current = !!unlocked;
47
+ if (unlocked && buildStartedForRef.current !== mailboxUid) {
48
+ buildStartedForRef.current = mailboxUid;
49
+ // Never an unhandled rejection - a broken local index (Worker/WASM/OPFS unsupported or
50
+ // unavailable, a corrupted store) is best-effort infrastructure, not a build the rest of the
51
+ // app depends on. searchTier2.ts's own callers already degrade gracefully independent of
52
+ // whether a build ever completed at all.
53
+ buildLocalIndex(mailboxUid, unlocked, folders).catch(() => undefined);
54
+ }
55
+ const interval = setInterval(() => {
56
+ const stillUnlocked = !!getUnlockedKeys(mailboxUid);
57
+ if (wasUnlockedRef.current && !stillUnlocked) {
58
+ // A present-to-absent transition: something just destroyed this mailbox's unlocked keys
59
+ // (idle timeout or the manual "Destroy keys now" button - see this module's own doc
60
+ // comment on why this is observed rather than hooked directly).
61
+ void destroyLocalIndex(mailboxUid);
62
+ buildStartedForRef.current = undefined;
63
+ }
64
+ else if (!wasUnlockedRef.current && stillUnlocked && buildStartedForRef.current !== mailboxUid) {
65
+ // The mailbox was re-unlocked this session (e.g. via ComposeWindow's or
66
+ // MessageDetailPane's own on-demand unlock prompt) - start the build it missed the first
67
+ // time around.
68
+ buildStartedForRef.current = mailboxUid;
69
+ buildLocalIndex(mailboxUid, getUnlockedKeys(mailboxUid), folders).catch(() => undefined);
70
+ }
71
+ wasUnlockedRef.current = stillUnlocked;
72
+ }, POLL_INTERVAL_MS);
73
+ return () => clearInterval(interval);
74
+ }, [mailboxUid, folders]);
75
+ return null;
76
+ }
@@ -0,0 +1,63 @@
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
+ /** The logical SQLite page size `EncryptingVFS` is designed for - MUST match the `PRAGMA page_size` the
17
+ * database was created with (`localIndexWorker.ts` sets this before the first write). */
18
+ export const LOGICAL_BLOCK_SIZE = 4096;
19
+ const NONCE_LENGTH = 12;
20
+ const TAG_LENGTH = 16;
21
+ /** One physical block on disk: `nonce || ciphertext || tag`. */
22
+ export const PHYSICAL_BLOCK_SIZE = LOGICAL_BLOCK_SIZE + NONCE_LENGTH + TAG_LENGTH;
23
+ /** Thrown when a stored block fails AES-GCM authentication - a wrong/rotated key, on-disk corruption, or
24
+ * a block moved to the wrong position (see `localIndexVFS.ts`'s doc comment on why AAD binds position).
25
+ * The worker's caller treats this identically to a schema-version mismatch: discard the whole index and
26
+ * rebuild (spec §11 "Invalidation"). */
27
+ export class PageCorruptedError extends Error {
28
+ constructor(filename, blockIndex, cause) {
29
+ super(`Local search index block ${blockIndex} of '${filename}' failed to decrypt.`);
30
+ this.cause = cause;
31
+ }
32
+ }
33
+ export async function importAesGcmKey(rawKey) {
34
+ return crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, ["encrypt", "decrypt"]);
35
+ }
36
+ function buildBlockAad(filename, blockIndex) {
37
+ return new TextEncoder().encode(`rapidmx-local-index-block:${filename}:${blockIndex}`);
38
+ }
39
+ /** Encrypts one logical block (`plaintext`, exactly `LOGICAL_BLOCK_SIZE` bytes) into its physical,
40
+ * on-disk representation (`PHYSICAL_BLOCK_SIZE` bytes: a fresh random nonce followed by
41
+ * ciphertext+tag). */
42
+ export async function encryptBlock(key, filename, blockIndex, plaintext) {
43
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
44
+ const ciphertextAndTag = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: buildBlockAad(filename, blockIndex) }, key, plaintext));
45
+ const physical = new Uint8Array(PHYSICAL_BLOCK_SIZE);
46
+ physical.set(nonce, 0);
47
+ physical.set(ciphertextAndTag, NONCE_LENGTH);
48
+ return physical;
49
+ }
50
+ /** Inverse of `encryptBlock()`. Throws `PageCorruptedError` (never a raw `DOMException`) on auth
51
+ * failure - a wrong key, tampered ciphertext, or a block swapped into the wrong position (caught by the
52
+ * position-bound AAD not matching). */
53
+ export async function decryptBlock(key, filename, blockIndex, physical) {
54
+ const nonce = physical.subarray(0, NONCE_LENGTH);
55
+ const ciphertextAndTag = physical.subarray(NONCE_LENGTH, PHYSICAL_BLOCK_SIZE);
56
+ try {
57
+ const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: buildBlockAad(filename, blockIndex) }, key, ciphertextAndTag);
58
+ return new Uint8Array(plaintext);
59
+ }
60
+ catch (err) {
61
+ throw new PageCorruptedError(filename, blockIndex, err);
62
+ }
63
+ }
@@ -0,0 +1,153 @@
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 } from "@rapidmx/react-shared/mail/mailApi.js";
28
+ import { evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
29
+ import { indexLocalEntities, initLocalIndex, setLocalIndexBuilding, setLocalIndexWindow } from "./localIndexRpcClient.js";
30
+ import { deriveLocalIndexKey } from "./localIndexKey.js";
31
+ import { getLocalIndexByteBudget } from "./localIndexSizePreference.js";
32
+ /** The RFC 9788 placeholder subject every encrypted message's outer envelope carries server-side - see
33
+ * `apps/www/index.tsx`'s identical constant and its own doc comment for the full citation. */
34
+ const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
35
+ /** §11's Window Sizing table's time-floor column - explicitly unvalidated starting-point default per the
36
+ * spec's own §16 "Measurement Task" ("cannot be supplied by design work... MUST NOT be treated as
37
+ * validated"). Used as-is rather than invented/adjusted here. Not user-adjustable today (unlike the byte
38
+ * budget - see `localIndexSizePreference.ts`) - nothing in this codebase has asked for that yet. */
39
+ export const WEB_TIME_FLOOR_MONTHS = 12;
40
+ /** Folder types that actually hold messages - excludes `calendar`/`contacts`/other non-mail folder types
41
+ * `FolderType` also covers. Inbox and Sent first: the two folders a "did I find that email" search is
42
+ * overwhelmingly likely to land in, so they're covered soonest if the build is interrupted (tab closed,
43
+ * idle timeout) partway through. */
44
+ const MESSAGE_FOLDER_TYPES = new Set(["inbox", "sent_items", "drafts", "deleted_items", "outbox", "junk"]);
45
+ const FOLDER_PRIORITY = { inbox: 0, sent_items: 1 };
46
+ const PAGE_SIZE = 100;
47
+ const MAX_APPROX_BYTES_PER_MESSAGE_PADDING = 512; // subject/participants/flags overhead beyond raw text length
48
+ /** `cutoff === undefined` means no time floor at all (an unbounded `windowConfig`) - every message is
49
+ * "within" it, so the caller's own end-of-folder check (`messages.length < PAGE_SIZE`) becomes the only
50
+ * stopping condition. */
51
+ function isWithinTimeFloor(receivedDate, cutoff) {
52
+ return !cutoff || new Date(receivedDate).getTime() >= cutoff.getTime();
53
+ }
54
+ function estimateByteSize(entity) {
55
+ const textLength = (entity.subject?.length ?? 0) + (entity.body?.length ?? 0) + (entity.attachmentText?.length ?? 0) + entity.participants.length;
56
+ return textLength + MAX_APPROX_BYTES_PER_MESSAGE_PADDING;
57
+ }
58
+ /** Decrypts one message and shapes it into a `LocalIndexEntity`, or `undefined` when nothing usable was
59
+ * recovered (a decrypt failure, or a message that turns out not to actually be encrypted despite the
60
+ * placeholder subject) - mirrors `searchTier3.ts`'s own `!security.html && !security.subject` discard
61
+ * rule exactly, for the same reason. */
62
+ async function buildEntity(message, unlocked) {
63
+ try {
64
+ const rawMime = await getMessageRawContent(message.uid);
65
+ const security = await evaluateMessageSecurity(rawMime, unlocked);
66
+ if (!security.subject && !security.html) {
67
+ return undefined;
68
+ }
69
+ const participants = [message.from.address, message.from.displayName, ...message.recipients.map((r) => r.address)]
70
+ .filter(Boolean)
71
+ .join(" ");
72
+ const setFlags = Object.entries(message.flags)
73
+ .filter(([, value]) => value)
74
+ .map(([key]) => key);
75
+ // Leading/trailing comma so `flags LIKE '%,x,%'` (localIndexSchema.ts's buildSearchPredicates())
76
+ // matches correctly even for the first/last flag in the list.
77
+ const flags = `,${setFlags.join(",")},`;
78
+ const entity = {
79
+ entityType: "message",
80
+ entityUid: message.uid,
81
+ mailboxUid: message.mailboxUid,
82
+ folderUid: message.folderUid,
83
+ dateForSort: message.receivedDate,
84
+ participants,
85
+ flags,
86
+ hasAttachments: message.hasAttachments,
87
+ subject: security.subject,
88
+ body: security.html,
89
+ byteSize: 0, // filled in below, after the fields above are known
90
+ };
91
+ entity.byteSize = estimateByteSize(entity);
92
+ return entity;
93
+ }
94
+ catch {
95
+ // Best-effort, matching searchTier3.ts's own Promise.allSettled-per-candidate posture - one
96
+ // message's fetch/decrypt failure never aborts the rest of the build.
97
+ return undefined;
98
+ }
99
+ }
100
+ /** Runs one full incremental build pass for `mailboxUid`: sets the window, walks mail folders newest-first
101
+ * (per this module's own doc comment on the folder-order simplification), decrypts and indexes only
102
+ * `"[...]"`-subject messages until each folder's own coverage passes the time floor, then clears the
103
+ * `building` flag. Never throws - a failure partway through leaves whatever was indexed so far in place
104
+ * (spec §11's "incomplete-index UX... MUST indicate that coverage is partial" is served by `coverage()`
105
+ * truthfully reporting whatever `indexedFrom` this run actually reached, not by this function needing to
106
+ * succeed completely).
107
+ *
108
+ * `windowConfig` defaults to this device's own configured byte budget (`getLocalIndexByteBudget()` -
109
+ * 500 MB in a browser tab, 1 GB in Electron, or whatever the user has since set in Settings > Encryption)
110
+ * alongside the fixed time floor above. Evaluated fresh on every call with no explicit override, so a
111
+ * preference change in Settings takes effect starting with this mailbox's next build pass (its next
112
+ * unlock), without requiring a reload.
113
+ */
114
+ export async function buildLocalIndex(mailboxUid, unlocked, folders, windowConfig = { timeFloorMonths: WEB_TIME_FLOOR_MONTHS, byteBudgetBytes: getLocalIndexByteBudget() }) {
115
+ const indexKey = await deriveLocalIndexKey(unlocked.masterKey, mailboxUid);
116
+ await initLocalIndex({ mailboxUid, indexKey });
117
+ await setLocalIndexWindow(mailboxUid, windowConfig.timeFloorMonths, windowConfig.byteBudgetBytes);
118
+ await setLocalIndexBuilding(mailboxUid, true);
119
+ try {
120
+ let cutoff;
121
+ if (windowConfig.timeFloorMonths > 0) {
122
+ cutoff = new Date();
123
+ cutoff.setMonth(cutoff.getMonth() - windowConfig.timeFloorMonths);
124
+ }
125
+ const mailFolders = folders
126
+ .filter((f) => MESSAGE_FOLDER_TYPES.has(f.type))
127
+ .sort((a, b) => (FOLDER_PRIORITY[a.type] ?? 99) - (FOLDER_PRIORITY[b.type] ?? 99));
128
+ for (const folder of mailFolders) {
129
+ let page = 0;
130
+ for (;;) {
131
+ const messages = await listMessages(folder.uid, { page, limit: PAGE_SIZE }).catch(() => []);
132
+ if (messages.length === 0) {
133
+ break;
134
+ }
135
+ const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
136
+ if (encrypted.length > 0) {
137
+ const entities = (await Promise.all(encrypted.map((m) => buildEntity(m, unlocked)))).filter((e) => e !== undefined);
138
+ if (entities.length > 0) {
139
+ await indexLocalEntities(mailboxUid, entities);
140
+ }
141
+ }
142
+ const oldestOnPage = messages[messages.length - 1];
143
+ if (!isWithinTimeFloor(oldestOnPage.receivedDate, cutoff) || messages.length < PAGE_SIZE) {
144
+ break;
145
+ }
146
+ page += 1;
147
+ }
148
+ }
149
+ }
150
+ finally {
151
+ await setLocalIndexBuilding(mailboxUid, false);
152
+ }
153
+ }
@@ -0,0 +1,25 @@
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
+ /** Fixed, non-secret HKDF salt - see this module's own doc comment for why a fixed salt is fine here. */
19
+ const FIXED_SALT = new TextEncoder().encode("rapidmx-local-search-index-v1");
20
+ /** Derives this mailbox's local-index page-encryption key from its already-unlocked master key. Every
21
+ * call with the same `(masterKey, mailboxUid)` pair returns the identical key - callers never need to
22
+ * persist it themselves. */
23
+ export async function deriveLocalIndexKey(masterKey, mailboxUid) {
24
+ return hkdfDerive(masterKey, FIXED_SALT, `local-search-index:${mailboxUid}`);
25
+ }
@@ -0,0 +1,78 @@
1
+ let worker;
2
+ let nextRequestId = 1;
3
+ const pending = new Map();
4
+ /** Every mailboxUid `initLocalIndex()` has opened this session, not yet `destroyLocalIndex()`-ed - lets
5
+ * `destroyAllLocalIndexes()` (called from `AppShell.tsx`'s sign-out, which has no mailboxUid of its own
6
+ * to pass - see that call site's own comment) destroy every open index without needing one threaded
7
+ * through. Mirrors `keySession.ts`'s own module-level `sessions` map in spirit - session-scoped,
8
+ * intentionally never persisted. */
9
+ const initializedMailboxes = new Set();
10
+ function getWorker() {
11
+ if (!worker) {
12
+ worker = new Worker(new URL("./localIndexWorker.ts", import.meta.url), { type: "module" });
13
+ worker.addEventListener("message", (event) => {
14
+ const entry = pending.get(event.data.id);
15
+ if (!entry) {
16
+ return;
17
+ }
18
+ pending.delete(event.data.id);
19
+ if (event.data.ok) {
20
+ entry.resolve(event.data.result);
21
+ }
22
+ else {
23
+ entry.reject(new Error(event.data.error));
24
+ }
25
+ });
26
+ }
27
+ return worker;
28
+ }
29
+ function call(method, params) {
30
+ const id = nextRequestId++;
31
+ return new Promise((resolve, reject) => {
32
+ pending.set(id, { resolve: resolve, reject });
33
+ getWorker().postMessage({ id, method, params });
34
+ });
35
+ }
36
+ export async function initLocalIndex(params) {
37
+ await call("init", params);
38
+ initializedMailboxes.add(params.mailboxUid);
39
+ }
40
+ export function indexLocalEntities(mailboxUid, entities) {
41
+ return call("indexEntities", { mailboxUid, entities });
42
+ }
43
+ export function removeLocalEntity(mailboxUid, entityUid) {
44
+ return call("removeEntity", { mailboxUid, entityUid });
45
+ }
46
+ export function searchLocal(mailboxUid, parsed, limit, offset = 0) {
47
+ return call("search", { mailboxUid, parsed, limit, offset });
48
+ }
49
+ export function getLocalCoverage(mailboxUid) {
50
+ return call("coverage", mailboxUid);
51
+ }
52
+ export function setLocalIndexWindow(mailboxUid, timeFloorMonths, byteBudgetBytes) {
53
+ return call("setWindow", { mailboxUid, timeFloorMonths, byteBudgetBytes });
54
+ }
55
+ export function setLocalIndexBuilding(mailboxUid, building) {
56
+ return call("setBuilding", { mailboxUid, building });
57
+ }
58
+ /** Destroys one mailbox's local index (both the SQLite connection and its on-disk OPFS storage) - spec
59
+ * §11 "MUST be destroyed on the same events that destroy private keys." Never throws: called from
60
+ * lifecycle hooks (idle timeout, logout, the manual "destroy keys now" button) where a destroy failure
61
+ * shouldn't block the key-destruction it's piggybacking on. */
62
+ export async function destroyLocalIndex(mailboxUid) {
63
+ try {
64
+ await call("destroy", mailboxUid);
65
+ }
66
+ catch {
67
+ // Best-effort - see this function's own doc comment.
68
+ }
69
+ finally {
70
+ initializedMailboxes.delete(mailboxUid);
71
+ }
72
+ }
73
+ /** Destroys every mailbox's local index this session has opened - for a caller (`AppShell.tsx`'s
74
+ * sign-out) that has no specific mailboxUid of its own, the same "clear everything" shape
75
+ * `destroyUnlockedKeys()` itself offers when called with no argument. */
76
+ export async function destroyAllLocalIndexes() {
77
+ await Promise.all([...initializedMailboxes].map((mailboxUid) => destroyLocalIndex(mailboxUid)));
78
+ }
@@ -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 SQLite schema (`specs/search.md` §13). One database per mailbox (see
7
+ * `localIndexWorker.ts`), holding both the FTS5 index and the metadata cache in the same store - the
8
+ * spec's own rationale for choosing SQLite over an in-memory JS index in the first place ("the index
9
+ * database also holds the decrypted metadata cache... so there is one local store rather than two").
10
+ *
11
+ * Bumping `SCHEMA_VERSION` is how a schema change invalidates every existing local index (§11
12
+ * "Invalidation... on schema version change") - `localIndexWorker.ts` compares it against `meta`'s
13
+ * stored value on open and discards+rebuilds on a mismatch, the same path a corruption/GCM-auth-failure
14
+ * takes.
15
+ */
16
+ /** Bump whenever `CREATE_SCHEMA_SQL` changes in a way existing on-disk databases can't be reconciled
17
+ * with in place. */
18
+ export const SCHEMA_VERSION = 1;
19
+ /**
20
+ * `entities` is the real row store (metadata + the plaintext content fields), `entities_fts` is an FTS5
21
+ * *external content* table over it (`content='entities'`) - the standard SQLite pattern for keeping one
22
+ * copy of the text instead of duplicating it into the FTS5 shadow tables, kept in sync via the three
23
+ * triggers below (SQLite's own documented pattern for external-content FTS5 tables; there is no
24
+ * "ON CONFLICT UPDATE re-index" primitive, so update is modeled as delete-then-reinsert into the FTS
25
+ * index specifically, not into `entities` itself).
26
+ *
27
+ * Column order in `entities_fts` (`subject, participants, body, attachment_text`) is load-bearing: every
28
+ * `bm25(entities_fts, 3.0, 2.0, 1.0, 1.0)` call elsewhere in this module family assumes that exact
29
+ * positional order, matching `searchScoring.ts`'s `SEARCH_FIELD_WEIGHTS` (`subject: 3, participants: 2,
30
+ * body: 1, attachmentText: 1`) so Tier 2's local ranking agrees with the Tier 1/Tier 3 re-scoring the
31
+ * spec requires for one consistent ordering across tiers (§7).
32
+ */
33
+ export const CREATE_SCHEMA_SQL = `
34
+ CREATE TABLE IF NOT EXISTS meta (
35
+ key TEXT PRIMARY KEY,
36
+ value TEXT
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS entities (
40
+ rowid INTEGER PRIMARY KEY,
41
+ entity_type TEXT NOT NULL,
42
+ entity_uid TEXT NOT NULL UNIQUE,
43
+ mailbox_uid TEXT NOT NULL,
44
+ folder_uid TEXT,
45
+ date_for_sort TEXT NOT NULL,
46
+ participants TEXT,
47
+ flags TEXT,
48
+ has_attachments INTEGER NOT NULL DEFAULT 0,
49
+ subject TEXT,
50
+ body TEXT,
51
+ attachment_text TEXT,
52
+ byte_size INTEGER NOT NULL DEFAULT 0
53
+ );
54
+ CREATE INDEX IF NOT EXISTS idx_entities_date ON entities(date_for_sort);
55
+ CREATE INDEX IF NOT EXISTS idx_entities_mailbox ON entities(mailbox_uid);
56
+
57
+ CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
58
+ subject, participants, body, attachment_text,
59
+ content='entities', content_rowid='rowid', tokenize='unicode61'
60
+ );
61
+
62
+ CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
63
+ INSERT INTO entities_fts(rowid, subject, participants, body, attachment_text)
64
+ VALUES (new.rowid, new.subject, new.participants, new.body, new.attachment_text);
65
+ END;
66
+
67
+ CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
68
+ INSERT INTO entities_fts(entities_fts, rowid, subject, participants, body, attachment_text)
69
+ VALUES ('delete', old.rowid, old.subject, old.participants, old.body, old.attachment_text);
70
+ END;
71
+
72
+ CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
73
+ INSERT INTO entities_fts(entities_fts, rowid, subject, participants, body, attachment_text)
74
+ VALUES ('delete', old.rowid, old.subject, old.participants, old.body, old.attachment_text);
75
+ INSERT INTO entities_fts(rowid, subject, participants, body, attachment_text)
76
+ VALUES (new.rowid, new.subject, new.participants, new.body, new.attachment_text);
77
+ END;
78
+ `;
79
+ /** The exact `bm25()` weight arguments every ranked query against `entities_fts` MUST pass, in column
80
+ * order - see this module's own doc comment on why the order is load-bearing. Centralized here so a
81
+ * future column reorder can't silently desync a query building its own literal weight list. */
82
+ export const BM25_WEIGHTS_SQL = "3.0, 2.0, 1.0, 1.0";
83
+ /** `entity_uid` upsert - `ON CONFLICT` (SQLite's UPSERT syntax) rather than a separate delete-then-insert,
84
+ * so re-indexing an already-present message (a flag changed, a folder move) updates it in place and the
85
+ * `entities_au` trigger keeps `entities_fts` in sync automatically. */
86
+ export const UPSERT_ENTITY_SQL = `
87
+ INSERT INTO entities (entity_type, entity_uid, mailbox_uid, folder_uid, date_for_sort, participants, flags, has_attachments, subject, body, attachment_text, byte_size)
88
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
89
+ ON CONFLICT(entity_uid) DO UPDATE SET
90
+ folder_uid = excluded.folder_uid, date_for_sort = excluded.date_for_sort, participants = excluded.participants,
91
+ flags = excluded.flags, has_attachments = excluded.has_attachments, subject = excluded.subject,
92
+ body = excluded.body, attachment_text = excluded.attachment_text, byte_size = excluded.byte_size
93
+ `;
94
+ /** Bind values for `UPSERT_ENTITY_SQL`, in column order - kept alongside it so the two can never drift
95
+ * out of sync with each other. */
96
+ export function entityBindValues(entity) {
97
+ return [
98
+ entity.entityType,
99
+ entity.entityUid,
100
+ entity.mailboxUid,
101
+ entity.folderUid ?? null,
102
+ entity.dateForSort,
103
+ entity.participants,
104
+ entity.flags,
105
+ entity.hasAttachments ? 1 : 0,
106
+ entity.subject ?? null,
107
+ entity.body ?? null,
108
+ entity.attachmentText ?? null,
109
+ entity.byteSize,
110
+ ];
111
+ }
112
+ /**
113
+ * Builds the SQL `WHERE` predicate (and its bind params) for every *structured* operator this local
114
+ * index can actually evaluate. **Known simplification**: unlike Tier 1's server-side `SearchDocument`
115
+ * (which splits `from`/`to`/`cc` into distinct fields - confirmed already implemented server-side), this
116
+ * local schema keeps only the combined `participants` field (see `CREATE_SCHEMA_SQL`'s own doc comment) -
117
+ * `from:`/`to:`/`cc:` are therefore evaluated here as a substring match against that combined field
118
+ * rather than a precise per-role match. Reasonable for a bounded, best-effort recent-window cache
119
+ * (Tier 1 already serves the precise version for anything it indexes), but a real gap if Tier 2 is later
120
+ * extended to distinguish them - flagged here rather than left silently approximate.
121
+ */
122
+ export function buildSearchPredicates(parsed, mailboxUid) {
123
+ const clauses = ["e.mailbox_uid = ?"];
124
+ const params = [mailboxUid];
125
+ if (parsed.folderUid) {
126
+ clauses.push("e.folder_uid = ?");
127
+ params.push(parsed.folderUid);
128
+ }
129
+ if (parsed.before) {
130
+ clauses.push("e.date_for_sort < ?");
131
+ params.push(parsed.before.toISOString());
132
+ }
133
+ if (parsed.after) {
134
+ clauses.push("e.date_for_sort > ?");
135
+ params.push(parsed.after.toISOString());
136
+ }
137
+ if (parsed.hasAttachment !== undefined) {
138
+ clauses.push("e.has_attachments = ?");
139
+ params.push(parsed.hasAttachment ? 1 : 0);
140
+ }
141
+ for (const flag of parsed.flags ?? []) {
142
+ clauses.push("e.flags LIKE ?");
143
+ params.push(`%,${flag},%`);
144
+ }
145
+ for (const participant of [parsed.from, parsed.to, parsed.cc]) {
146
+ if (participant) {
147
+ clauses.push("e.participants LIKE ?");
148
+ params.push(`%${participant}%`);
149
+ }
150
+ }
151
+ return { where: clauses.join(" AND "), params };
152
+ }
153
+ /** Escapes a free-text fragment for safe embedding inside an FTS5 `MATCH` phrase - FTS5's own quoting
154
+ * rule for a `"..."` phrase is doubling an embedded `"`, mirroring SQL string-literal escaping. */
155
+ function escapeFtsPhrase(value) {
156
+ return value.replace(/"/g, '""');
157
+ }
158
+ /**
159
+ * Builds the FTS5 `MATCH` expression for a parsed query's free-text and `subject:` portions, or
160
+ * `undefined` when there's nothing to match on text at all (a pure operator/structured-filter query -
161
+ * `buildSearchPredicates()`'s `WHERE` clause alone already narrows that case correctly, no `MATCH`
162
+ * needed). `parsed.text` is passed through close to verbatim (quoted phrases, `OR`, `-` negation - FTS5's
163
+ * own query syntax supports the same shape `queryGrammar.ts`'s own doc comment says every provider's
164
+ * free-text engine is expected to), wrapped only enough to combine it with a `subject:`-scoped clause
165
+ * when both are present.
166
+ */
167
+ export function buildMatchExpression(parsed) {
168
+ const parts = [];
169
+ if (parsed.subject) {
170
+ parts.push(`subject:"${escapeFtsPhrase(parsed.subject)}"`);
171
+ }
172
+ if (parsed.text.trim()) {
173
+ parts.push(parsed.subject ? `(${parsed.text})` : parsed.text);
174
+ }
175
+ if (parts.length === 0) {
176
+ return undefined;
177
+ }
178
+ return parts.join(" AND ");
179
+ }