@rapidmx/web-client 0.15.1 → 0.16.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 (189) hide show
  1. package/README.md +373 -373
  2. package/apps/admin/branding/index.tsx +39 -39
  3. package/apps/admin/data-requests/index.tsx +482 -480
  4. package/apps/admin/domains/[uid].tsx +164 -164
  5. package/apps/admin/escrow-scopes/[uid].tsx +348 -348
  6. package/apps/admin/index.tsx +127 -124
  7. package/apps/admin/mailboxes/[uid].tsx +269 -224
  8. package/apps/admin/mailboxes/new/index.tsx +28 -28
  9. package/apps/admin/plugins/index.tsx +15 -15
  10. package/apps/admin/retention-policy/index.tsx +39 -39
  11. package/apps/admin/signing-certificates/index.tsx +343 -343
  12. package/apps/escrow/audit-log/index.tsx +196 -196
  13. package/apps/escrow/matters/[uid].tsx +621 -621
  14. package/apps/shared/auth/adminAccess.ts +99 -99
  15. package/apps/shared/components/admin/layout/AdminShell.tsx +378 -378
  16. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
  17. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  18. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
  19. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  20. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  21. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
  22. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  23. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
  24. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  25. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  26. package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
  27. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  28. package/apps/shared/components/calendar/EventEditor.tsx +5 -10
  29. package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
  30. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  31. package/apps/shared/components/calendar/GuestInput.tsx +100 -0
  32. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  33. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  34. package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
  35. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  36. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  37. package/apps/shared/components/calendar/allDay.ts +124 -124
  38. package/apps/shared/components/calendar/eventForm.ts +2 -2
  39. package/apps/shared/components/calendar/eventFormat.ts +49 -22
  40. package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
  41. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
  43. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
  44. package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  46. package/apps/shared/components/layout/AppShell.tsx +480 -480
  47. package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  50. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  51. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  52. package/apps/shared/components/layout/UserMenu.tsx +439 -424
  53. package/apps/shared/components/mail/ConversationList.tsx +323 -323
  54. package/apps/shared/components/mail/ConversationThreadPane.tsx +612 -491
  55. package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
  56. package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1559
  57. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  58. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  59. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
  60. package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
  61. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  62. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  63. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  64. package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
  65. package/apps/shared/components/mail/layout/MailShell.tsx +137 -113
  66. package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
  67. package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
  68. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  69. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  70. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  71. package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -254
  72. package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
  73. package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
  74. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  75. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  76. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  77. package/apps/shared/keyboard/dispatch.ts +124 -124
  78. package/apps/shared/keyboard/format.ts +89 -89
  79. package/apps/shared/keyboard/keymap.ts +114 -114
  80. package/apps/shared/keyboard/registry.ts +65 -65
  81. package/apps/shared/keyboard/targets.ts +79 -79
  82. package/apps/shared/mail/folderCounts.ts +17 -0
  83. package/apps/shared/mail/folderTree.ts +143 -143
  84. package/apps/shared/mail/listAllPages.ts +39 -39
  85. package/apps/shared/mail/newMailNotifications.ts +171 -171
  86. package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
  87. package/apps/shared/mail/outbox/sendJob.ts +19 -1
  88. package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
  89. package/apps/shared/mail/primaryMailbox.ts +71 -0
  90. package/apps/shared/mail/useCollapsedSections.ts +146 -0
  91. package/apps/shared/mail/useMailConnection.ts +205 -203
  92. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  93. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  94. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  95. package/apps/shared/navigation/AppRouter.tsx +300 -300
  96. package/apps/shared/navigation/routerContext.tsx +83 -83
  97. package/apps/shared/notifications/store.ts +560 -560
  98. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  99. package/apps/shared/search/crossMailboxSearch.ts +169 -0
  100. package/apps/shared/search/localIndexBuilder.ts +481 -481
  101. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  102. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  103. package/apps/shared/signing/enrollmentView.ts +251 -251
  104. package/apps/shared/signing/useNow.ts +19 -19
  105. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  106. package/apps/shared/styles/app.css +396 -386
  107. package/apps/www/calendar/index.tsx +578 -571
  108. package/apps/www/contacts/[uid].tsx +109 -109
  109. package/apps/www/contacts/index.tsx +6 -0
  110. package/apps/www/index.tsx +2847 -2479
  111. package/apps/www/messages/[uid].tsx +135 -135
  112. package/apps/www/settings/auto-reply/index.tsx +133 -133
  113. package/apps/www/settings/encryption/index.tsx +1287 -1287
  114. package/apps/www/settings/filters/[uid].tsx +176 -176
  115. package/apps/www/settings/filters/index.tsx +102 -102
  116. package/apps/www/settings/filters/new/index.tsx +140 -140
  117. package/apps/www/settings/labels/index.tsx +204 -204
  118. package/apps/www/settings/privacy/index.tsx +492 -492
  119. package/apps/www/settings/profile/index.tsx +248 -248
  120. package/apps/www/settings/read-receipts/index.tsx +147 -147
  121. package/apps/www/settings/sharing/index.tsx +256 -256
  122. package/apps/www/settings/signatures/[uid].tsx +172 -172
  123. package/apps/www/settings/signatures/index.tsx +88 -88
  124. package/apps/www/settings/signatures/new/index.tsx +135 -135
  125. package/apps/www/tasks/index.tsx +649 -649
  126. package/dist/apps/admin/data-requests/index.js +1 -1
  127. package/dist/apps/admin/index.js +2 -1
  128. package/dist/apps/admin/mailboxes/[uid].js +12 -2
  129. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
  130. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
  131. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
  132. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
  133. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
  134. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
  136. package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
  137. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
  138. package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
  139. package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
  140. package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
  141. package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
  142. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
  143. package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
  144. package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
  145. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
  146. package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
  147. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  148. package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
  149. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
  150. package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
  151. package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
  152. package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
  153. package/dist/apps/shared/components/layout/UserMenu.js +6 -5
  154. package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
  155. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
  156. package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
  157. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
  158. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
  159. package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
  160. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
  161. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
  162. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  163. package/dist/apps/shared/components/mail/layout/MailShell.js +64 -45
  164. package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
  165. package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
  166. package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
  167. package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
  168. package/dist/apps/shared/components/settings/layout/SettingsShell.js +6 -5
  169. package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
  170. package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
  171. package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
  172. package/dist/apps/shared/mail/folderCounts.js +12 -0
  173. package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
  174. package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
  175. package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
  176. package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
  177. package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
  178. package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
  179. package/dist/apps/shared/mail/primaryMailbox.js +61 -0
  180. package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
  181. package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
  182. package/dist/apps/shared/mail/useMailConnection.js +3 -1
  183. package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
  184. package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
  185. package/dist/apps/shared/styles/app.css +396 -386
  186. package/dist/apps/www/calendar/index.js +3 -2
  187. package/dist/apps/www/contacts/index.js +3 -1
  188. package/dist/apps/www/index.js +426 -189
  189. package/package.json +2 -2
@@ -1,114 +1,114 @@
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: starts the background build for the active mailbox once it's
7
- * unlocked, and destroys an index on the same events that destroy that mailbox's unlocked keys (spec §11
8
- * "MUST be destroyed on the same events that destroy private keys: explicit logout, session revocation,
9
- * 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. Watching `getUnlockedKeys()`
13
- * for a present-to-absent transition catches *every* destroy path uniformly - idle timeout, the manual
14
- * button, or anything else that calls it - without needing to know which one fired.
15
- *
16
- * Every mailbox this component has seen unlocked is tracked for the life of the mount - not just whichever
17
- * is active right now - so switching mailboxes (or a folders-list refresh re-running the build effect)
18
- * can never lose a pending unlocked-to-locked transition for a mailbox whose index was already built.
19
- *
20
- * **Explicit logout** navigates away immediately, which may not leave time for a poll tick - `AppShell.tsx`
21
- * handles that case itself via `destroyAllLocalIndexes()`.
22
- */
23
- import { useEffect, useRef } from "react";
24
- import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
25
- import type { PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
26
- import type { Folder } from "@rapidmx/react-shared/mail/mailApi.js";
27
-
28
- /**
29
- * The index builder (with the message security code it decrypts through - PKI.js and the ASN.1/X.509 libraries, over half a
30
- * megabyte) is loaded only once a mailbox is actually unlocked and a build is due, so an inbox that never unlocks - the
31
- * common case - never downloads it.
32
- */
33
- let builder: Promise<typeof import("./localIndexBuilder.js")> | undefined;
34
- const loadBuilder = () =>
35
- (builder ??= import("./localIndexBuilder.js").catch((err) => {
36
- // A failed download is tried again the next time one is due.
37
- builder = undefined;
38
- throw err;
39
- }));
40
- import { destroyLocalIndex, pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
41
-
42
- export const POLL_INTERVAL_MS = 5_000;
43
-
44
- export interface LocalIndexLifecycleProps {
45
- mailboxUid?: string;
46
- mailboxKeys?: PublicKey[];
47
- folders: Folder[];
48
- /** Every mailbox the signed-in user can currently access. Once known, indexes on this device for any
49
- * other mailbox (e.g. left behind by a different user who never signed out) are removed. */
50
- accessibleMailboxUids?: string[];
51
- }
52
-
53
- /** Renders nothing - pure side-effect component, mounted by `MailShell.tsx`. */
54
- export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMailboxUids }: LocalIndexLifecycleProps) {
55
- // mailboxUid -> whether it was unlocked at the last check. An entry exists for every mailbox whose
56
- // build this component started; only ever changed by a real observed transition, never reset by an
57
- // effect re-run.
58
- const trackedRef = useRef(new Map<string, boolean>());
59
- // The latest props, for the long-lived polling interval below.
60
- const latestRef = useRef({ mailboxUid, folders });
61
- latestRef.current = { mailboxUid, folders };
62
-
63
- function startBuildIfUnlocked(targetMailboxUid: string, targetFolders: Folder[]) {
64
- const unlocked = getUnlockedKeys(targetMailboxUid);
65
- if (!unlocked || targetFolders.length === 0 || trackedRef.current.get(targetMailboxUid)) {
66
- return;
67
- }
68
- trackedRef.current.set(targetMailboxUid, true);
69
- // Never an unhandled rejection - a broken local index is best-effort infrastructure, not a build
70
- // the rest of the app depends on.
71
- loadBuilder()
72
- .then(({ buildLocalIndex }) => buildLocalIndex(targetMailboxUid, unlocked, targetFolders))
73
- .catch(() => undefined);
74
- }
75
-
76
- useEffect(() => {
77
- if (mailboxUid) {
78
- startBuildIfUnlocked(mailboxUid, folders);
79
- }
80
- }, [mailboxUid, folders]);
81
-
82
- useEffect(() => {
83
- const interval = setInterval(() => {
84
- for (const [trackedUid, wasUnlocked] of trackedRef.current) {
85
- if (wasUnlocked && !getUnlockedKeys(trackedUid)) {
86
- // A present-to-absent transition: something just destroyed this mailbox's keys.
87
- trackedRef.current.set(trackedUid, false);
88
- // Stop the pass still running with the now-destroyed keys; the destroy itself also makes
89
- // the Worker reject anything that pass still sends.
90
- loadBuilder()
91
- .then(({ cancelLocalIndexBuild }) => cancelLocalIndexBuild(trackedUid))
92
- .catch(() => undefined);
93
- void destroyLocalIndex(trackedUid);
94
- }
95
- }
96
- // The active mailbox was (re-)unlocked since the last check, e.g. via an on-demand unlock prompt.
97
- const { mailboxUid: activeUid, folders: activeFolders } = latestRef.current;
98
- if (activeUid) {
99
- startBuildIfUnlocked(activeUid, activeFolders);
100
- }
101
- }, POLL_INTERVAL_MS);
102
- return () => clearInterval(interval);
103
- }, []);
104
-
105
- // A stable string key so a re-render with an equal-but-new array doesn't re-scan OPFS.
106
- const accessibleKey = accessibleMailboxUids && accessibleMailboxUids.length > 0 ? JSON.stringify([...accessibleMailboxUids].sort()) : "";
107
- useEffect(() => {
108
- if (accessibleKey) {
109
- void pruneInaccessibleLocalIndexes(JSON.parse(accessibleKey) as string[]);
110
- }
111
- }, [accessibleKey]);
112
-
113
- return null;
114
- }
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: starts the background build for the active mailbox once it's
7
+ * unlocked, and destroys an index on the same events that destroy that mailbox's unlocked keys (spec §11
8
+ * "MUST be destroyed on the same events that destroy private keys: explicit logout, session revocation,
9
+ * 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. Watching `getUnlockedKeys()`
13
+ * for a present-to-absent transition catches *every* destroy path uniformly - idle timeout, the manual
14
+ * button, or anything else that calls it - without needing to know which one fired.
15
+ *
16
+ * Every mailbox this component has seen unlocked is tracked for the life of the mount - not just whichever
17
+ * is active right now - so switching mailboxes (or a folders-list refresh re-running the build effect)
18
+ * can never lose a pending unlocked-to-locked transition for a mailbox whose index was already built.
19
+ *
20
+ * **Explicit logout** navigates away immediately, which may not leave time for a poll tick - `AppShell.tsx`
21
+ * handles that case itself via `destroyAllLocalIndexes()`.
22
+ */
23
+ import { useEffect, useRef } from "react";
24
+ import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
25
+ import type { PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
26
+ import type { Folder } from "@rapidmx/react-shared/mail/mailApi.js";
27
+
28
+ /**
29
+ * The index builder (with the message security code it decrypts through - PKI.js and the ASN.1/X.509 libraries, over half a
30
+ * megabyte) is loaded only once a mailbox is actually unlocked and a build is due, so an inbox that never unlocks - the
31
+ * common case - never downloads it.
32
+ */
33
+ let builder: Promise<typeof import("./localIndexBuilder.js")> | undefined;
34
+ const loadBuilder = () =>
35
+ (builder ??= import("./localIndexBuilder.js").catch((err) => {
36
+ // A failed download is tried again the next time one is due.
37
+ builder = undefined;
38
+ throw err;
39
+ }));
40
+ import { destroyLocalIndex, pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
41
+
42
+ export const POLL_INTERVAL_MS = 5_000;
43
+
44
+ export interface LocalIndexLifecycleProps {
45
+ mailboxUid?: string;
46
+ mailboxKeys?: PublicKey[];
47
+ folders: Folder[];
48
+ /** Every mailbox the signed-in user can currently access. Once known, indexes on this device for any
49
+ * other mailbox (e.g. left behind by a different user who never signed out) are removed. */
50
+ accessibleMailboxUids?: string[];
51
+ }
52
+
53
+ /** Renders nothing - pure side-effect component, mounted by `MailShell.tsx`. */
54
+ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMailboxUids }: LocalIndexLifecycleProps) {
55
+ // mailboxUid -> whether it was unlocked at the last check. An entry exists for every mailbox whose
56
+ // build this component started; only ever changed by a real observed transition, never reset by an
57
+ // effect re-run.
58
+ const trackedRef = useRef(new Map<string, boolean>());
59
+ // The latest props, for the long-lived polling interval below.
60
+ const latestRef = useRef({ mailboxUid, folders });
61
+ latestRef.current = { mailboxUid, folders };
62
+
63
+ function startBuildIfUnlocked(targetMailboxUid: string, targetFolders: Folder[]) {
64
+ const unlocked = getUnlockedKeys(targetMailboxUid);
65
+ if (!unlocked || targetFolders.length === 0 || trackedRef.current.get(targetMailboxUid)) {
66
+ return;
67
+ }
68
+ trackedRef.current.set(targetMailboxUid, true);
69
+ // Never an unhandled rejection - a broken local index is best-effort infrastructure, not a build
70
+ // the rest of the app depends on.
71
+ loadBuilder()
72
+ .then(({ buildLocalIndex }) => buildLocalIndex(targetMailboxUid, unlocked, targetFolders))
73
+ .catch(() => undefined);
74
+ }
75
+
76
+ useEffect(() => {
77
+ if (mailboxUid) {
78
+ startBuildIfUnlocked(mailboxUid, folders);
79
+ }
80
+ }, [mailboxUid, folders]);
81
+
82
+ useEffect(() => {
83
+ const interval = setInterval(() => {
84
+ for (const [trackedUid, wasUnlocked] of trackedRef.current) {
85
+ if (wasUnlocked && !getUnlockedKeys(trackedUid)) {
86
+ // A present-to-absent transition: something just destroyed this mailbox's keys.
87
+ trackedRef.current.set(trackedUid, false);
88
+ // Stop the pass still running with the now-destroyed keys; the destroy itself also makes
89
+ // the Worker reject anything that pass still sends.
90
+ loadBuilder()
91
+ .then(({ cancelLocalIndexBuild }) => cancelLocalIndexBuild(trackedUid))
92
+ .catch(() => undefined);
93
+ void destroyLocalIndex(trackedUid);
94
+ }
95
+ }
96
+ // The active mailbox was (re-)unlocked since the last check, e.g. via an on-demand unlock prompt.
97
+ const { mailboxUid: activeUid, folders: activeFolders } = latestRef.current;
98
+ if (activeUid) {
99
+ startBuildIfUnlocked(activeUid, activeFolders);
100
+ }
101
+ }, POLL_INTERVAL_MS);
102
+ return () => clearInterval(interval);
103
+ }, []);
104
+
105
+ // A stable string key so a re-render with an equal-but-new array doesn't re-scan OPFS.
106
+ const accessibleKey = accessibleMailboxUids && accessibleMailboxUids.length > 0 ? JSON.stringify([...accessibleMailboxUids].sort()) : "";
107
+ useEffect(() => {
108
+ if (accessibleKey) {
109
+ void pruneInaccessibleLocalIndexes(JSON.parse(accessibleKey) as string[]);
110
+ }
111
+ }, [accessibleKey]);
112
+
113
+ return null;
114
+ }
@@ -0,0 +1,169 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The pieces of searching several mailboxes at once (Mail's "All mailboxes" views, and a search widened from one mailbox's folder to every
7
+ * mailbox the reader can read). Nothing here talks to the server: `apps/www/index.tsx` fans its own per-mailbox pipeline (Tier 1 server search,
8
+ * Tier 2 local index, Tier 3 server-narrowed encrypted candidates) out across mailboxes and uses these to bound that fan-out, to merge what
9
+ * comes back into one ranked list and to say, in words, which mailbox could not be searched and why.
10
+ *
11
+ * A search of one mailbox is the same code with a list of one: nothing here changes what that returns.
12
+ */
13
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
14
+ import type { SearchResult } from "@rapidmx/react-shared/search/searchApi.js";
15
+ import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
16
+
17
+ /**
18
+ * How far a cross-mailbox search fans out. Exported (and mutable) so a test can shrink them instead of building 26 mailboxes or waiting a
19
+ * minute for a timeout; nothing else writes to it.
20
+ */
21
+ export const SEARCH_LIMITS = {
22
+ /** How many mailboxes are worked on at once. */
23
+ concurrency: 4,
24
+ /** The most mailboxes one search covers - the caller's own first, then the shared ones (`orderMailboxes()`); a note says how many were left out. */
25
+ maxMailboxes: 25,
26
+ /** How long one mailbox's Tier 1 (server) request may take before that mailbox is given up on, so one wedged mailbox cannot hold the search. */
27
+ tier1TimeoutMs: 15_000,
28
+ /** The same for a mailbox's Tier 3 pass (fetching and decrypting up to a couple of hundred candidates, so much longer). */
29
+ tier3TimeoutMs: 60_000,
30
+ /** The most result rows a search keeps on screen (the page's own cap for a list, `MAX_LOADED_ROWS`, applies to a search too). */
31
+ maxRows: 500,
32
+ };
33
+
34
+ /** The least a mailbox is asked for on one page while several are searched, so a search over many mailboxes still moves at a sensible pace. */
35
+ const MIN_MAILBOX_PAGE_SIZE = 10;
36
+
37
+ /**
38
+ * How many results each mailbox contributes to one page. One mailbox gives the whole page; with several the page is shared out between them
39
+ * (never below `MIN_MAILBOX_PAGE_SIZE`), so the merged first page stays about one page long instead of growing by the number of mailboxes.
40
+ */
41
+ export function searchPageSize(mailboxCount: number, pageSize: number): number {
42
+ return Math.max(MIN_MAILBOX_PAGE_SIZE, Math.ceil(pageSize / Math.max(1, mailboxCount)));
43
+ }
44
+
45
+ /** A search hit that remembers which mailbox it was found in. */
46
+ export interface MailboxHit extends SearchResult {
47
+ mailboxUid: string;
48
+ }
49
+
50
+ export function tagHits(mailboxUid: string, hits: SearchResult[]): MailboxHit[] {
51
+ return hits.map((hit) => ({ ...hit, mailboxUid }));
52
+ }
53
+
54
+ /** One hit's identity across mailboxes: the same entity uid found in two mailboxes is two hits. */
55
+ export function hitKey(hit: { mailboxUid: string; entityUid: string }): string {
56
+ return `${hit.mailboxUid}\u0000${hit.entityUid}`;
57
+ }
58
+
59
+ /**
60
+ * Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message), Tier 2 (local index, fully decrypted and re-scored) and Tier 3
61
+ * (server-narrowed candidates, decrypted and re-scored) results into one ranked list, per `specs/search.md` §7's "client MUST re-score all
62
+ * results it can see... normalise into the same space rather than interleaving raw scores": each tier is normalized independently via
63
+ * `normalizeServerScores()` before merging, since a Postgres/OpenSearch score, a local `bm25()` score, and Tier 3's own term-count score all
64
+ * occupy unrelated ranges. A hit present in more than one list keeps only the last-inserted entry (Tier 2 wins over Tier 3 wins over Tier 1) -
65
+ * Tier 2 and Tier 3 both represent genuine, content-verified scores for the same message, so which one "wins" on overlap doesn't change
66
+ * correctness, only which of two equally-valid scores is shown; either supersedes Tier 1's metadata-only guess for the same message.
67
+ *
68
+ * Each tier's list is pooled across every mailbox searched before it is normalized, so one mailbox's best hit is not automatically "1.0"
69
+ * next to another's: the ranking is the same rule as for one mailbox, applied to the merged lists. A hit's identity is its mailbox and its
70
+ * entity uid (`hitKey()`), so the same uid can only collapse within a mailbox. Ties keep the order the lists were given in (a stable sort), which
71
+ * the caller makes the mailboxes' display order.
72
+ *
73
+ * Called progressively - each time with whatever tiers have reported so far (an empty array for the rest). Pure and stateless.
74
+ */
75
+ export function mergeSearchResults(tier1: MailboxHit[], tier2: MailboxHit[], tier3: MailboxHit[]): MailboxHit[] {
76
+ const merged = new Map<string, { result: MailboxHit; normalizedScore: number }>();
77
+ for (const tier of [tier1, tier3, tier2]) {
78
+ for (const entry of normalizeServerScores(tier)) {
79
+ merged.set(hitKey(entry.result), entry);
80
+ }
81
+ }
82
+ return Array.from(merged.values())
83
+ .sort((a, b) => b.normalizedScore - a.normalizedScore)
84
+ .map((entry) => entry.result);
85
+ }
86
+
87
+ /** Raised by `withTimeout()`. */
88
+ export class SearchTimeoutError extends Error {
89
+ constructor() {
90
+ super("The search took too long.");
91
+ this.name = "SearchTimeoutError";
92
+ }
93
+ }
94
+
95
+ /** `promise`, or a rejection with `SearchTimeoutError` if it has not settled after `ms` (never, for a non-finite `ms`). The work is not aborted - a late answer is just dropped. */
96
+ export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
97
+ if (!Number.isFinite(ms)) {
98
+ return promise;
99
+ }
100
+ return new Promise<T>((resolve, reject) => {
101
+ const timer = setTimeout(() => reject(new SearchTimeoutError()), ms);
102
+ promise.then(
103
+ (value) => {
104
+ clearTimeout(timer);
105
+ resolve(value);
106
+ },
107
+ (reason) => {
108
+ clearTimeout(timer);
109
+ reject(reason);
110
+ },
111
+ );
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Runs `task` for every item, at most `concurrency` at a time, starting them in order. Stops starting new ones once `isCancelled()` (the search
117
+ * was superseded) or once one has rejected - which is then this function's own rejection, after the ones already running have settled.
118
+ */
119
+ export async function runLimited<T>(
120
+ items: readonly T[],
121
+ concurrency: number,
122
+ task: (item: T) => Promise<void>,
123
+ isCancelled: () => boolean,
124
+ ): Promise<void> {
125
+ let next = 0;
126
+ let failed = false;
127
+ const worker = async () => {
128
+ while (!failed && !isCancelled() && next < items.length) {
129
+ const item = items[next++];
130
+ try {
131
+ await task(item);
132
+ } catch (err) {
133
+ failed = true;
134
+ throw err;
135
+ }
136
+ }
137
+ };
138
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
139
+ }
140
+
141
+ /** Why one mailbox's search failed, as a short phrase for the notice: the class of failure, never the server's own text. */
142
+ export function classifyFailure(err: unknown): string {
143
+ if (err instanceof SearchTimeoutError) {
144
+ return "timed out";
145
+ }
146
+ if (err instanceof ApiRequestError) {
147
+ if (err.status === 401 || err.status === 403) {
148
+ return "access denied";
149
+ }
150
+ return err.status >= 500 ? "server error" : "rejected by the server";
151
+ }
152
+ return "network error";
153
+ }
154
+
155
+ /** A mailbox that could not be (fully) searched. `encrypted` is set when only its Tier 3 pass (the encrypted mail) failed - its other results are in. */
156
+ export interface MailboxFailure {
157
+ mailboxUid: string;
158
+ /** The mailbox's display name, as it was when the search ran. */
159
+ mailboxName: string;
160
+ encrypted: boolean;
161
+ reason: string;
162
+ }
163
+
164
+ /** The body of the "Some results may be missing" notice: which mailboxes, and why each. */
165
+ export function describeFailures(failures: readonly MailboxFailure[]): string {
166
+ return `${failures
167
+ .map((failure) => `${failure.mailboxName} (${failure.encrypted ? "encrypted mail: " : ""}${failure.reason})`)
168
+ .join(", ")} could not be searched.`;
169
+ }