@rapidmx/web-client 0.4.0 → 0.5.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 (169) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/data-requests/index.tsx +480 -485
  3. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  4. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  5. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  6. package/apps/admin/index.tsx +19 -1
  7. package/apps/admin/ingest-queue/index.tsx +44 -5
  8. package/apps/admin/mailboxes/[uid].tsx +211 -184
  9. package/apps/admin/quarantine/index.tsx +46 -14
  10. package/apps/admin/transport-rules/[uid].tsx +30 -1
  11. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  12. package/apps/admin/transport-rules/index.tsx +24 -6
  13. package/apps/admin/transport-rules/new/index.tsx +30 -1
  14. package/apps/escrow/_layout.tsx +2 -3
  15. package/apps/escrow/audit-log/index.tsx +196 -168
  16. package/apps/escrow/matters/[uid].tsx +621 -521
  17. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  18. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
  19. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  20. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  21. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
  23. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
  25. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
  26. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
  27. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
  28. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
  31. package/apps/shared/components/admin/signOut.ts +79 -0
  32. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  33. package/apps/shared/components/calendar/EventModal.tsx +717 -549
  34. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  35. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  36. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  37. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  38. package/apps/shared/components/calendar/allDay.ts +124 -0
  39. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  40. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  41. package/apps/shared/components/contacts/ContactForm.tsx +377 -348
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  43. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  44. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  46. package/apps/shared/components/layout/AppShell.tsx +87 -15
  47. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  50. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  51. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
  52. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  53. package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
  54. package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
  55. package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  57. package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
  58. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  59. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  60. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  61. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  62. package/apps/shared/mail/listAllPages.ts +39 -0
  63. package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
  64. package/apps/shared/search/localIndexBuilder.ts +481 -179
  65. package/apps/shared/search/localIndexRpcClient.ts +228 -28
  66. package/apps/shared/search/localIndexSchema.ts +15 -6
  67. package/apps/shared/search/localIndexStorage.ts +98 -0
  68. package/apps/shared/search/localIndexVFS.ts +32 -1
  69. package/apps/shared/search/localIndexWorker.ts +648 -186
  70. package/apps/www/calendar/index.tsx +438 -417
  71. package/apps/www/contacts/[uid].tsx +21 -2
  72. package/apps/www/contacts/index.tsx +116 -12
  73. package/apps/www/index.tsx +1314 -1041
  74. package/apps/www/messages/[uid].tsx +18 -6
  75. package/apps/www/settings/auto-reply/index.tsx +134 -129
  76. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  77. package/apps/www/settings/encryption/index.tsx +739 -216
  78. package/apps/www/settings/filters/[uid].tsx +171 -155
  79. package/apps/www/settings/filters/new/index.tsx +8 -1
  80. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  81. package/apps/www/settings/privacy/index.tsx +494 -436
  82. package/apps/www/settings/read-receipts/index.tsx +148 -125
  83. package/apps/www/settings/sharing/index.tsx +17 -14
  84. package/apps/www/tasks/index.tsx +638 -579
  85. package/dist/apps/admin/audit-log/index.js +38 -8
  86. package/dist/apps/admin/data-requests/index.js +52 -72
  87. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  88. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  89. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  90. package/dist/apps/admin/index.js +4 -1
  91. package/dist/apps/admin/ingest-queue/index.js +24 -6
  92. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  93. package/dist/apps/admin/quarantine/index.js +20 -11
  94. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  95. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  96. package/dist/apps/admin/transport-rules/index.js +22 -6
  97. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  98. package/dist/apps/escrow/_layout.js +3 -3
  99. package/dist/apps/escrow/audit-log/index.js +26 -1
  100. package/dist/apps/escrow/matters/[uid].js +77 -42
  101. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  102. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
  103. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  104. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  105. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  106. package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
  107. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
  108. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
  109. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
  110. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
  111. package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
  112. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
  113. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
  114. package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
  115. package/dist/apps/shared/components/admin/signOut.js +75 -0
  116. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  117. package/dist/apps/shared/components/calendar/EventModal.js +142 -21
  118. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  119. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  120. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  121. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  122. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  123. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  124. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  125. package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
  126. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  127. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  128. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  129. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  130. package/dist/apps/shared/components/layout/AppShell.js +83 -14
  131. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  132. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
  133. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  134. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  135. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
  136. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  137. package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
  138. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  139. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
  140. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  141. package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
  142. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  143. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  144. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  145. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  146. package/dist/apps/shared/mail/listAllPages.js +25 -0
  147. package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
  148. package/dist/apps/shared/search/localIndexBuilder.js +292 -37
  149. package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
  150. package/dist/apps/shared/search/localIndexSchema.js +12 -6
  151. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  152. package/dist/apps/shared/search/localIndexVFS.js +68 -40
  153. package/dist/apps/shared/search/localIndexWorker.js +519 -173
  154. package/dist/apps/www/calendar/index.js +28 -10
  155. package/dist/apps/www/contacts/[uid].js +13 -2
  156. package/dist/apps/www/contacts/index.js +80 -10
  157. package/dist/apps/www/index.js +339 -108
  158. package/dist/apps/www/messages/[uid].js +18 -6
  159. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  160. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  161. package/dist/apps/www/settings/encryption/index.js +514 -97
  162. package/dist/apps/www/settings/filters/[uid].js +22 -7
  163. package/dist/apps/www/settings/filters/new/index.js +8 -2
  164. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  165. package/dist/apps/www/settings/privacy/index.js +57 -22
  166. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  167. package/dist/apps/www/settings/sharing/index.js +7 -11
  168. package/dist/apps/www/tasks/index.js +50 -10
  169. package/package.json +2 -2
@@ -3,74 +3,78 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
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").
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
10
  *
11
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
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
18
14
  * button, or anything else that calls it - without needing to know which one fired.
19
15
  *
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.
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()`.
24
22
  */
25
23
  import { useEffect, useRef } from "react";
26
24
  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) {
25
+ import { buildLocalIndex, cancelLocalIndexBuild } from "./localIndexBuilder.js";
26
+ import { destroyLocalIndex, pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
27
+ export const POLL_INTERVAL_MS = 5000;
28
+ /** Renders nothing - pure side-effect component, mounted by `MailShell.tsx`. */
29
+ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMailboxUids }) {
30
+ // mailboxUid -> whether it was unlocked at the last check. An entry exists for every mailbox whose
31
+ // build this component started; only ever changed by a real observed transition, never reset by an
32
+ // effect re-run.
33
+ const trackedRef = useRef(new Map());
34
+ // The latest props, for the long-lived polling interval below.
35
+ const latestRef = useRef({ mailboxUid, folders });
36
+ latestRef.current = { mailboxUid, folders };
37
+ function startBuildIfUnlocked(targetMailboxUid, targetFolders) {
38
+ const unlocked = getUnlockedKeys(targetMailboxUid);
39
+ if (!unlocked || targetFolders.length === 0 || trackedRef.current.get(targetMailboxUid)) {
43
40
  return;
44
41
  }
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);
42
+ trackedRef.current.set(targetMailboxUid, true);
43
+ // Never an unhandled rejection - a broken local index is best-effort infrastructure, not a build
44
+ // the rest of the app depends on.
45
+ buildLocalIndex(targetMailboxUid, unlocked, targetFolders).catch(() => undefined);
46
+ }
47
+ useEffect(() => {
48
+ if (mailboxUid) {
49
+ startBuildIfUnlocked(mailboxUid, folders);
54
50
  }
51
+ }, [mailboxUid, folders]);
52
+ useEffect(() => {
55
53
  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;
54
+ for (const [trackedUid, wasUnlocked] of trackedRef.current) {
55
+ if (wasUnlocked && !getUnlockedKeys(trackedUid)) {
56
+ // A present-to-absent transition: something just destroyed this mailbox's keys.
57
+ trackedRef.current.set(trackedUid, false);
58
+ // Stop the pass still running with the now-destroyed keys; the destroy itself also makes
59
+ // the Worker reject anything that pass still sends.
60
+ void cancelLocalIndexBuild(trackedUid);
61
+ void destroyLocalIndex(trackedUid);
62
+ }
63
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);
64
+ // The active mailbox was (re-)unlocked since the last check, e.g. via an on-demand unlock prompt.
65
+ const { mailboxUid: activeUid, folders: activeFolders } = latestRef.current;
66
+ if (activeUid) {
67
+ startBuildIfUnlocked(activeUid, activeFolders);
70
68
  }
71
- wasUnlockedRef.current = stillUnlocked;
72
69
  }, POLL_INTERVAL_MS);
73
70
  return () => clearInterval(interval);
74
- }, [mailboxUid, folders]);
71
+ }, []);
72
+ // A stable string key so a re-render with an equal-but-new array doesn't re-scan OPFS.
73
+ const accessibleKey = accessibleMailboxUids && accessibleMailboxUids.length > 0 ? JSON.stringify([...accessibleMailboxUids].sort()) : "";
74
+ useEffect(() => {
75
+ if (accessibleKey) {
76
+ void pruneInaccessibleLocalIndexes(JSON.parse(accessibleKey));
77
+ }
78
+ }, [accessibleKey]);
75
79
  return null;
76
80
  }
@@ -23,10 +23,23 @@
23
23
  * own precedent in `apps/www/index.tsx`'s inbox-list decrypt work. An unencrypted message is already
24
24
  * fully searchable via Tier 1; indexing it here too would spend this index's bounded byte budget on
25
25
  * content that didn't need it.
26
+ *
27
+ * **Verification seals.** A message this pass decrypts that has no seal for the vault's current `masterKeyGeneration` is
28
+ * evaluated with `evaluateMessageSecurityWithSeal()`, against the sender's pinned signing keys from the reader's
29
+ * contacts (`pinnedSigners.ts`, the message pane's own source, looked up once per sender per pass), so a verified
30
+ * message gets its seal without being opened (see `apps/shared/components/mail/verificationSeals.ts`). Pins only change
31
+ * the security state, never the recovered subject or body this index stores. Seal writes are best effort and bounded:
32
+ * at most `SEAL_WRITE_CONCURRENCY` in flight, at most `MAX_SEAL_WRITES_PER_PASS` per pass (past it, messages are
33
+ * evaluated without seals), failures ignored, and nothing new starts once the pass is aborted or its keys are destroyed.
34
+ * A seal carries only a hash, fingerprint, state and time, never plaintext. With no readable vault generation the pass
35
+ * seals nothing. Signed-only (unencrypted) messages aren't sealed here: this pass never fetches their raw MIME (see
36
+ * above), and fetching it only to seal would cost a download per message; they are sealed when opened.
26
37
  */
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";
38
+ import { getMessageRawContent, listFolders, listMessages } from "@rapidmx/react-shared/mail/mailApi.js";
39
+ import { evaluateMessageSecurity, evaluateMessageSecurityWithSeal } from "@rapidmx/react-shared/crypto/messageSecurity.js";
40
+ import { getPinnedSignerFingerprints } from "../components/mail/pinnedSigners.js";
41
+ import { currentVerificationSeal, readVaultGeneration, sendVerificationSeal, verificationSealPending } from "../components/mail/verificationSeals.js";
42
+ import { getIndexedVersions, indexLocalEntities, initLocalIndex, nextLocalIndexGeneration, pruneLocalEntities, setLocalIndexBuilding, setLocalIndexWindow, } from "./localIndexRpcClient.js";
30
43
  import { deriveLocalIndexKey } from "./localIndexKey.js";
31
44
  import { getLocalIndexByteBudget } from "./localIndexSizePreference.js";
32
45
  /** The RFC 9788 placeholder subject every encrypted message's outer envelope carries server-side - see
@@ -37,32 +50,143 @@ const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
37
50
  * validated"). Used as-is rather than invented/adjusted here. Not user-adjustable today (unlike the byte
38
51
  * budget - see `localIndexSizePreference.ts`) - nothing in this codebase has asked for that yet. */
39
52
  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"]);
53
+ /** Folder types that actually hold messages, including `user` (custom folders). Inbox and Sent first: the two
54
+ * folders a "did I find that email" search is overwhelmingly likely to land in, so they're covered soonest if
55
+ * the build is interrupted (tab closed, idle timeout) partway through. */
56
+ const MESSAGE_FOLDER_TYPES = new Set(["inbox", "sent_items", "drafts", "deleted_items", "outbox", "junk", "archive", "user"]);
57
+ /** Folder types known not to hold mail. Any folder in neither set is skipped, and the pass is then not
58
+ * complete - a type this module doesn't know about may well hold messages. */
59
+ const NON_MESSAGE_FOLDER_TYPES = new Set(["calendar", "contacts", "tasks", "notes"]);
45
60
  const FOLDER_PRIORITY = { inbox: 0, sent_items: 1 };
61
+ /** How many times a pass walks a folder whose total count changed during the walk. `listMessages()` only
62
+ * pages by offset, so a message removed from an already-walked page shifts a later one onto it unseen;
63
+ * only a folder whose count held steady across a whole walk is trusted for completeness and pruning. */
64
+ export const MAX_WALK_ATTEMPTS = 3;
65
+ /** Subtracted from the pass's start time before it's recorded as the end of guaranteed coverage - a
66
+ * message's server-assigned `receivedDate` can be ahead of this device's clock. */
67
+ export const CLOCK_SKEW_MARGIN_MS = 10 * 60 * 1000;
46
68
  const PAGE_SIZE = 100;
47
69
  const MAX_APPROX_BYTES_PER_MESSAGE_PADDING = 512; // subject/participants/flags overhead beyond raw text length
70
+ const FETCH_FAILED = Symbol("fetch-failed");
71
+ /** How many raw-MIME fetch+decrypt calls run at once - a page of 100 encrypted messages used to fire all
72
+ * 100 requests simultaneously. */
73
+ export const FETCH_CONCURRENCY = 6;
74
+ /** How many verification seal writes run at once during a pass. */
75
+ export const SEAL_WRITE_CONCURRENCY = 2;
76
+ /** The most verification seals one pass writes. */
77
+ export const MAX_SEAL_WRITES_PER_PASS = 200;
78
+ function createPassSealer(mailboxUid, unlocked, signal) {
79
+ let generation;
80
+ const pins = new Map();
81
+ const queue = [];
82
+ let queued = 0;
83
+ let active = 0;
84
+ let idle;
85
+ const stopped = () => signal.aborted || !!unlocked.destroyed;
86
+ const vaultGeneration = () => (generation ?? (generation = readVaultGeneration(mailboxUid)));
87
+ const pinsFor = (address) => {
88
+ const key = address.toLowerCase();
89
+ let entry = pins.get(key);
90
+ if (!entry) {
91
+ entry = getPinnedSignerFingerprints(mailboxUid, address).then((fingerprints) => (fingerprints.length > 0 ? fingerprints : undefined), () => undefined);
92
+ pins.set(key, entry);
93
+ }
94
+ return entry;
95
+ };
96
+ function pump() {
97
+ if (stopped()) {
98
+ queue.length = 0;
99
+ }
100
+ while (active < SEAL_WRITE_CONCURRENCY && queue.length > 0) {
101
+ const write = queue.shift();
102
+ active++;
103
+ void write().finally(() => {
104
+ active--;
105
+ pump();
106
+ });
107
+ }
108
+ if (active === 0 && queue.length === 0) {
109
+ idle?.();
110
+ }
111
+ }
112
+ return {
113
+ async evaluate(message, rawMime) {
114
+ const current = await vaultGeneration();
115
+ const { seal, sealGeneration } = currentVerificationSeal(message);
116
+ if (current === undefined || stopped() || queued >= MAX_SEAL_WRITES_PER_PASS || !verificationSealPending(message.uid, current) || (seal && sealGeneration === current)) {
117
+ return evaluateMessageSecurity(rawMime, unlocked);
118
+ }
119
+ const result = await evaluateMessageSecurityWithSeal(rawMime, unlocked, await pinsFor(message.from.address), undefined, {
120
+ mailboxUid,
121
+ messageUid: message.uid,
122
+ seal,
123
+ sealGeneration,
124
+ masterKeyGeneration: current,
125
+ });
126
+ const sealToWrite = result.sealToWrite;
127
+ if (sealToWrite && queued < MAX_SEAL_WRITES_PER_PASS) {
128
+ queued++;
129
+ queue.push(() => sendVerificationSeal(message.uid, sealToWrite));
130
+ pump();
131
+ }
132
+ return result;
133
+ },
134
+ drain() {
135
+ return new Promise((resolve) => {
136
+ idle = resolve;
137
+ pump();
138
+ });
139
+ },
140
+ };
141
+ }
48
142
  /** `cutoff === undefined` means no time floor at all (an unbounded `windowConfig`) - every message is
49
143
  * "within" it, so the caller's own end-of-folder check (`messages.length < PAGE_SIZE`) becomes the only
50
144
  * stopping condition. */
51
145
  function isWithinTimeFloor(receivedDate, cutoff) {
52
146
  return !cutoff || new Date(receivedDate).getTime() >= cutoff.getTime();
53
147
  }
148
+ /** A relative weight only - the worker enforces the budget against the database's real measured size and
149
+ * uses these per-row values just to decide how much of the oldest range to evict at once. UTF-8 bytes,
150
+ * not UTF-16 code units. */
54
151
  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;
152
+ const encoder = new TextEncoder();
153
+ const textBytes = [entity.subject, entity.body, entity.attachmentText, entity.participants].reduce((total, value) => total + (value ? encoder.encode(value).length : 0), 0);
154
+ return textBytes + MAX_APPROX_BYTES_PER_MESSAGE_PADDING;
155
+ }
156
+ /** The change marker stored per row: a new server `version` (flags, content) or a folder move both
157
+ * invalidate the indexed copy. */
158
+ function versionOf(message) {
159
+ return `${message.version}:${message.folderUid}`;
160
+ }
161
+ /** `Promise.all(items.map(fn))`, but with at most `limit` calls in flight. Preserves input order. */
162
+ async function mapWithConcurrency(items, limit, fn) {
163
+ const results = new Array(items.length);
164
+ let next = 0;
165
+ async function worker() {
166
+ while (next < items.length) {
167
+ const index = next++;
168
+ results[index] = await fn(items[index]);
169
+ }
170
+ }
171
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
172
+ return results;
57
173
  }
58
174
  /** Decrypts one message and shapes it into a `LocalIndexEntity`, or `undefined` when nothing usable was
59
175
  * recovered (a decrypt failure, or a message that turns out not to actually be encrypted despite the
60
176
  * placeholder subject) - mirrors `searchTier3.ts`'s own `!security.html && !security.subject` discard
61
177
  * rule exactly, for the same reason. */
62
- async function buildEntity(message, unlocked) {
178
+ async function buildEntity(message, sealer) {
179
+ let rawMime;
180
+ try {
181
+ rawMime = await getMessageRawContent(message.uid);
182
+ }
183
+ catch {
184
+ // Distinct from "undecryptable": a message that couldn't even be fetched may well be searchable,
185
+ // so the pass can't claim complete coverage.
186
+ return FETCH_FAILED;
187
+ }
63
188
  try {
64
- const rawMime = await getMessageRawContent(message.uid);
65
- const security = await evaluateMessageSecurity(rawMime, unlocked);
189
+ const security = await sealer.evaluate(message, rawMime);
66
190
  if (!security.subject && !security.html) {
67
191
  return undefined;
68
192
  }
@@ -87,6 +211,7 @@ async function buildEntity(message, unlocked) {
87
211
  subject: security.subject,
88
212
  body: security.html,
89
213
  byteSize: 0, // filled in below, after the fields above are known
214
+ entityVersion: versionOf(message),
90
215
  };
91
216
  entity.byteSize = estimateByteSize(entity);
92
217
  return entity;
@@ -100,10 +225,15 @@ async function buildEntity(message, unlocked) {
100
225
  /** Runs one full incremental build pass for `mailboxUid`: sets the window, walks mail folders newest-first
101
226
  * (per this module's own doc comment on the folder-order simplification), decrypts and indexes only
102
227
  * `"[...]"`-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).
228
+ * `building` flag. Messages already indexed at the same version are skipped, fetches run at most
229
+ * `FETCH_CONCURRENCY` at a time, and messages older than the eviction watermark (`WindowState.evictedBefore`)
230
+ * are neither fetched nor walked past. A failure partway through leaves whatever was indexed so far in place
231
+ * and records the pass as incomplete (spec §11's "incomplete-index UX... MUST indicate that coverage is
232
+ * partial" - see `Coverage.complete`). Rows the server no longer lists (deleted or moved elsewhere by any
233
+ * client) are pruned only within folders whose count held steady for a whole walk (see `MAX_WALK_ATTEMPTS`).
234
+ *
235
+ * Only one pass runs per mailbox (a newer call cancels the older), and every Worker call carries this pass's
236
+ * generation, so a pass outlived by a destroy can't write anything (see `cancelLocalIndexBuild()`).
107
237
  *
108
238
  * `windowConfig` defaults to this device's own configured byte budget (`getLocalIndexByteBudget()` -
109
239
  * 500 MB in a browser tab, 1 GB in Electron, or whatever the user has since set in Settings > Encryption)
@@ -112,42 +242,167 @@ async function buildEntity(message, unlocked) {
112
242
  * unlock), without requiring a reload.
113
243
  */
114
244
  export async function buildLocalIndex(mailboxUid, unlocked, folders, windowConfig = { timeFloorMonths: WEB_TIME_FLOOR_MONTHS, byteBudgetBytes: getLocalIndexByteBudget() }) {
245
+ // At most one pass per mailbox: a newer one cancels the older and waits for it to wind down first.
246
+ const previous = activeBuilds.get(mailboxUid);
247
+ const controller = new AbortController();
248
+ const run = (async () => {
249
+ if (previous) {
250
+ previous.controller.abort();
251
+ await previous.settled;
252
+ }
253
+ await runBuildPass(mailboxUid, unlocked, folders, windowConfig, controller.signal);
254
+ })();
255
+ const entry = { controller, settled: run.catch(() => undefined) };
256
+ activeBuilds.set(mailboxUid, entry);
257
+ try {
258
+ await run;
259
+ }
260
+ finally {
261
+ if (activeBuilds.get(mailboxUid) === entry) {
262
+ activeBuilds.delete(mailboxUid);
263
+ }
264
+ }
265
+ }
266
+ const activeBuilds = new Map();
267
+ /** Stops a mailbox's running build pass, if any (e.g. its keys were just destroyed), resolving once it has
268
+ * wound down. The Worker independently rejects the pass's later calls once the index is destroyed (see
269
+ * `GenerationParams`); this just stops the fetching and decrypting too. Never rejects. */
270
+ export async function cancelLocalIndexBuild(mailboxUid) {
271
+ const active = activeBuilds.get(mailboxUid);
272
+ if (active) {
273
+ active.controller.abort();
274
+ await active.settled;
275
+ }
276
+ }
277
+ function isOlderThan(date, watermark) {
278
+ return !!watermark && new Date(date).getTime() < new Date(watermark).getTime();
279
+ }
280
+ async function runBuildPass(mailboxUid, unlocked, folders, windowConfig, signal) {
281
+ const generation = nextLocalIndexGeneration();
115
282
  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);
283
+ signal.throwIfAborted();
284
+ await initLocalIndex({ mailboxUid, indexKey, generation });
285
+ let { evictedBefore } = await setLocalIndexWindow(mailboxUid, windowConfig.timeFloorMonths, windowConfig.byteBudgetBytes, generation);
286
+ await setLocalIndexBuilding(mailboxUid, true, { complete: false, generation });
287
+ // Mail arriving after this moment isn't guaranteed to be picked up by this pass.
288
+ const coveredUntil = new Date(Date.now() - CLOCK_SKEW_MARGIN_MS).toISOString();
289
+ let cutoff;
290
+ if (windowConfig.timeFloorMonths > 0) {
291
+ cutoff = new Date();
292
+ cutoff.setMonth(cutoff.getMonth() - windowConfig.timeFloorMonths);
293
+ }
294
+ // A pass is "complete" only if every folder was walked back to the time floor (or its end) with a stable
295
+ // count, no listing/fetch failure and no budget cut-off - the only case where coverage can be used to
296
+ // narrow Tier 3.
297
+ let complete = true;
298
+ const sealer = createPassSealer(mailboxUid, unlocked, signal);
119
299
  try {
120
- let cutoff;
121
- if (windowConfig.timeFloorMonths > 0) {
122
- cutoff = new Date();
123
- cutoff.setMonth(cutoff.getMonth() - windowConfig.timeFloorMonths);
300
+ if (folders.some((f) => !MESSAGE_FOLDER_TYPES.has(f.type) && !NON_MESSAGE_FOLDER_TYPES.has(f.type))) {
301
+ complete = false;
124
302
  }
125
303
  const mailFolders = folders
126
304
  .filter((f) => MESSAGE_FOLDER_TYPES.has(f.type))
127
305
  .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(() => []);
306
+ const seenEncryptedUids = new Set();
307
+ async function walkFolder(folder) {
308
+ for (let page = 0;; page++) {
309
+ signal.throwIfAborted();
310
+ let messages;
311
+ try {
312
+ messages = await listMessages(folder.uid, { page, limit: PAGE_SIZE });
313
+ }
314
+ catch {
315
+ return "failed";
316
+ }
132
317
  if (messages.length === 0) {
133
- break;
318
+ return "done";
134
319
  }
135
320
  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);
321
+ for (const m of encrypted) {
322
+ seenEncryptedUids.add(m.uid);
323
+ }
324
+ // Anything older than the eviction watermark would only be inserted and evicted again.
325
+ const candidates = encrypted.filter((m) => !isOlderThan(m.receivedDate, evictedBefore));
326
+ if (candidates.length > 0) {
327
+ // Skip anything already indexed at the same version - a re-unlock used to re-fetch and
328
+ // re-decrypt the entire window every time.
329
+ const indexed = await getIndexedVersions(mailboxUid, candidates.map((m) => m.uid));
330
+ const changed = candidates.filter((m) => indexed[m.uid] !== versionOf(m));
331
+ const built = await mapWithConcurrency(changed, FETCH_CONCURRENCY, (m) => buildEntity(m, sealer));
332
+ signal.throwIfAborted();
333
+ if (built.includes(FETCH_FAILED)) {
334
+ complete = false;
335
+ }
336
+ const entities = built.filter((e) => e !== undefined && e !== FETCH_FAILED);
138
337
  if (entities.length > 0) {
139
- await indexLocalEntities(mailboxUid, entities);
338
+ ({ evictedBefore } = await indexLocalEntities(mailboxUid, entities, generation));
140
339
  }
141
340
  }
142
- const oldestOnPage = messages[messages.length - 1];
143
- if (!isWithinTimeFloor(oldestOnPage.receivedDate, cutoff) || messages.length < PAGE_SIZE) {
144
- break;
341
+ const oldestOnPage = messages[messages.length - 1].receivedDate;
342
+ if (evictedBefore && isOlderThan(oldestOnPage, evictedBefore) && isWithinTimeFloor(evictedBefore, cutoff)) {
343
+ return "budget";
344
+ }
345
+ if (!isWithinTimeFloor(oldestOnPage, cutoff) || messages.length < PAGE_SIZE) {
346
+ return "done";
145
347
  }
146
- page += 1;
147
348
  }
148
349
  }
350
+ /** Each folder's current total, or `undefined` if the listing failed. */
351
+ async function folderTotals() {
352
+ try {
353
+ return new Map((await listFolders(mailboxUid)).map((f) => [f.uid, f.totalCount]));
354
+ }
355
+ catch {
356
+ return undefined;
357
+ }
358
+ }
359
+ // Folders walked with a steady count and no listing failure - the only ones whose unseen rows can be
360
+ // pruned (deleted, or moved elsewhere by any client).
361
+ const reliable = [];
362
+ let totalsBefore = await folderTotals();
363
+ let toWalk = mailFolders;
364
+ for (let attempt = 0; attempt < MAX_WALK_ATTEMPTS && toWalk.length > 0; attempt++) {
365
+ const outcomes = new Map();
366
+ for (const folder of toWalk) {
367
+ outcomes.set(folder.uid, await walkFolder(folder));
368
+ }
369
+ const totalsAfter = await folderTotals();
370
+ const changed = [];
371
+ for (const folder of toWalk) {
372
+ const outcome = outcomes.get(folder.uid);
373
+ if (outcome === "failed" || !totalsBefore || !totalsAfter) {
374
+ // A failed listing, or no counts to vouch for the walk with: indexed as far as it got, but
375
+ // neither complete nor prunable (see the `reliable` check below).
376
+ }
377
+ else if (totalsAfter.get(folder.uid) === totalsBefore.get(folder.uid)) {
378
+ reliable.push(folder.uid);
379
+ complete && (complete = outcome === "done");
380
+ }
381
+ else {
382
+ changed.push(folder);
383
+ }
384
+ }
385
+ totalsBefore = totalsAfter;
386
+ toWalk = changed;
387
+ }
388
+ // Failed, count-less, or still changing after every attempt.
389
+ if (reliable.length < mailFolders.length) {
390
+ complete = false;
391
+ }
392
+ if (reliable.length > 0) {
393
+ // Every reliable folder was walked back to at least the watermark (or the floor).
394
+ const since = evictedBefore && isWithinTimeFloor(evictedBefore, cutoff) ? evictedBefore : cutoff?.toISOString();
395
+ await pruneLocalEntities(mailboxUid, [...seenEncryptedUids], since, { folderUids: reliable, generation });
396
+ }
397
+ }
398
+ catch (err) {
399
+ complete = false;
400
+ throw err;
149
401
  }
150
402
  finally {
151
- await setLocalIndexBuilding(mailboxUid, false);
403
+ // Rejected (harmlessly) when the index was destroyed mid-pass.
404
+ await setLocalIndexBuilding(mailboxUid, false, { complete, coveredFrom: cutoff?.toISOString(), coveredUntil, generation }).catch(() => undefined);
405
+ // At most `SEAL_WRITE_CONCURRENCY` writes are still in flight; queued ones are dropped if the pass was aborted.
406
+ await sealer.drain();
152
407
  }
153
408
  }