@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
@@ -1,12 +1,21 @@
1
+ import { removeLocalIndexDirectories, removeLocalIndexDirectory } from "./localIndexStorage.js";
1
2
  let worker;
2
3
  let nextRequestId = 1;
3
4
  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. */
5
+ /** Every mailboxUid `initLocalIndex()` has opened this session, not yet `destroyLocalIndex()`-ed. Gates
6
+ * the in-session mutation helpers (`removeLocalEntity()`/`moveLocalEntity()`) so a delete/move in a
7
+ * mailbox this tab never indexed doesn't spin up the Worker just to no-op. */
9
8
  const initializedMailboxes = new Set();
9
+ let generationCounter = 0;
10
+ /** A fresh generation - see this module's doc comment. */
11
+ export function nextLocalIndexGeneration() {
12
+ generationCounter += 1;
13
+ return generationCounter;
14
+ }
15
+ /** Set once this tab (or another one) signed out - no index may be opened again in this page load. */
16
+ let signedOut = false;
17
+ export const SIGN_OUT_CHANNEL = "rapidmx-localsearch";
18
+ let channel;
10
19
  function getWorker() {
11
20
  if (!worker) {
12
21
  worker = new Worker(new URL("./localIndexWorker.ts", import.meta.url), { type: "module" });
@@ -23,25 +32,131 @@ function getWorker() {
23
32
  entry.reject(new Error(event.data.error));
24
33
  }
25
34
  });
35
+ // Only a tab with a Worker has connections to close when another tab signs out.
36
+ listenForSignOut();
26
37
  }
27
38
  return worker;
28
39
  }
40
+ function getChannel() {
41
+ if (!channel && typeof BroadcastChannel !== "undefined") {
42
+ channel = new BroadcastChannel(SIGN_OUT_CHANNEL);
43
+ }
44
+ return channel;
45
+ }
46
+ function listenForSignOut() {
47
+ getChannel()?.addEventListener("message", (event) => {
48
+ if (event.data?.type === "sign-out") {
49
+ void closeEverythingInThisTab();
50
+ }
51
+ });
52
+ }
53
+ /** Another tab signed out: stop every build here and close this tab's connections (which also removes their
54
+ * directories, now that nothing holds them open). Never rejects. */
55
+ async function closeEverythingInThisTab() {
56
+ signedOut = true;
57
+ initializedMailboxes.clear();
58
+ await call("destroyAll", { generation: nextLocalIndexGeneration() }).catch(() => undefined);
59
+ }
29
60
  function call(method, params) {
30
61
  const id = nextRequestId++;
31
62
  return new Promise((resolve, reject) => {
32
63
  pending.set(id, { resolve: resolve, reject });
33
- getWorker().postMessage({ id, method, params });
64
+ try {
65
+ getWorker().postMessage({ id, method, params });
66
+ }
67
+ catch (err) {
68
+ pending.delete(id);
69
+ reject(err instanceof Error ? err : new Error(String(err)));
70
+ }
34
71
  });
35
72
  }
73
+ export const PENDING_DELETIONS_KEY = "rapidmx-localsearch-pending-deletions";
74
+ /** Stands for "every index on this device" in the pending-deletions list - recorded before a sign-out starts,
75
+ * so a navigation that kills it midway still gets finished on the next load. */
76
+ const ALL_INDEXES = "*";
77
+ function readPendingDeletions() {
78
+ try {
79
+ const parsed = JSON.parse(localStorage.getItem(PENDING_DELETIONS_KEY) ?? "[]");
80
+ return Array.isArray(parsed) ? parsed.filter((uid) => typeof uid === "string") : [];
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ }
86
+ function writePendingDeletions(uids) {
87
+ try {
88
+ const list = [...new Set(uids)];
89
+ if (list.length === 0) {
90
+ localStorage.removeItem(PENDING_DELETIONS_KEY);
91
+ }
92
+ else {
93
+ localStorage.setItem(PENDING_DELETIONS_KEY, JSON.stringify(list));
94
+ }
95
+ }
96
+ catch {
97
+ // Storage blocked - nothing to retry from, the same as before this existed.
98
+ }
99
+ }
100
+ function updatePendingDeletions(update) {
101
+ const current = new Set(readPendingDeletions());
102
+ update(current);
103
+ writePendingDeletions(current);
104
+ }
105
+ let pendingRetry;
106
+ /** Retries every deletion an earlier page load couldn't finish. Runs once per page load (memoized), before
107
+ * this tab's first `init` - so it never races an index this tab itself opens. Never rejects. */
108
+ export function retryPendingLocalIndexDeletions() {
109
+ pendingRetry ?? (pendingRetry = (async () => {
110
+ const uids = readPendingDeletions();
111
+ if (uids.length === 0) {
112
+ return;
113
+ }
114
+ const failed = [];
115
+ if (uids.includes(ALL_INDEXES)) {
116
+ const result = await removeLocalIndexDirectories().catch(() => ({ failed: [ALL_INDEXES] }));
117
+ failed.push(...result.failed);
118
+ }
119
+ else {
120
+ for (const uid of uids) {
121
+ await removeLocalIndexDirectory(uid).catch(() => failed.push(uid));
122
+ }
123
+ }
124
+ writePendingDeletions(failed);
125
+ })());
126
+ return pendingRetry;
127
+ }
36
128
  export async function initLocalIndex(params) {
129
+ await retryPendingLocalIndexDeletions();
130
+ if (signedOut) {
131
+ throw new Error("Signed out - the local search index is unavailable in this tab.");
132
+ }
37
133
  await call("init", params);
38
134
  initializedMailboxes.add(params.mailboxUid);
39
135
  }
40
- export function indexLocalEntities(mailboxUid, entities) {
41
- return call("indexEntities", { mailboxUid, entities });
136
+ export function indexLocalEntities(mailboxUid, entities, generation) {
137
+ return call("indexEntities", { mailboxUid, entities, generation });
138
+ }
139
+ /** Drops one message from this session's local index (a delete). A no-op for a mailbox not indexed in
140
+ * this tab - a later build pass prunes it instead. Never rejects: best-effort housekeeping. */
141
+ export async function removeLocalEntity(mailboxUid, entityUid) {
142
+ if (!initializedMailboxes.has(mailboxUid)) {
143
+ return;
144
+ }
145
+ await call("removeEntity", { mailboxUid, entityUid }).catch(() => undefined);
146
+ }
147
+ /** Re-points one indexed message at its new folder (archive, cancel-scheduled-send) so `folder:`-scoped
148
+ * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntity()`. */
149
+ export async function moveLocalEntity(mailboxUid, entityUid, folderUid) {
150
+ if (!initializedMailboxes.has(mailboxUid)) {
151
+ return;
152
+ }
153
+ await call("moveEntity", { mailboxUid, entityUid, folderUid }).catch(() => undefined);
154
+ }
155
+ export function getIndexedVersions(mailboxUid, entityUids) {
156
+ return call("indexedVersions", { mailboxUid, entityUids });
42
157
  }
43
- export function removeLocalEntity(mailboxUid, entityUid) {
44
- return call("removeEntity", { mailboxUid, entityUid });
158
+ export function pruneLocalEntities(mailboxUid, keepEntityUids, since, options = {}) {
159
+ return call("pruneEntities", { mailboxUid, keepEntityUids, since, ...options });
45
160
  }
46
161
  export function searchLocal(mailboxUid, parsed, limit, offset = 0) {
47
162
  return call("search", { mailboxUid, parsed, limit, offset });
@@ -49,30 +164,78 @@ export function searchLocal(mailboxUid, parsed, limit, offset = 0) {
49
164
  export function getLocalCoverage(mailboxUid) {
50
165
  return call("coverage", mailboxUid);
51
166
  }
52
- export function setLocalIndexWindow(mailboxUid, timeFloorMonths, byteBudgetBytes) {
53
- return call("setWindow", { mailboxUid, timeFloorMonths, byteBudgetBytes });
167
+ export function setLocalIndexWindow(mailboxUid, timeFloorMonths, byteBudgetBytes, generation) {
168
+ return call("setWindow", { mailboxUid, timeFloorMonths, byteBudgetBytes, generation });
54
169
  }
55
- export function setLocalIndexBuilding(mailboxUid, building) {
56
- return call("setBuilding", { mailboxUid, building });
170
+ export function setLocalIndexBuilding(mailboxUid, building, completion = { complete: false }) {
171
+ return call("setBuilding", { mailboxUid, building, ...completion });
57
172
  }
58
173
  /** 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. */
174
+ * §11 "MUST be destroyed on the same events that destroy private keys." Never rejects (it's called from
175
+ * lifecycle hooks where a failure mustn't block key destruction) but resolves `false` - and logs why -
176
+ * when the index could not be removed, e.g. because another tab still has it open; the deletion is then
177
+ * retried on the next page load. */
62
178
  export async function destroyLocalIndex(mailboxUid) {
179
+ updatePendingDeletions((current) => current.add(mailboxUid));
63
180
  try {
64
- await call("destroy", mailboxUid);
181
+ await call("destroy", { mailboxUid, generation: nextLocalIndexGeneration() });
182
+ updatePendingDeletions((current) => current.delete(mailboxUid));
183
+ return true;
65
184
  }
66
- catch {
67
- // Best-effort - see this function's own doc comment.
185
+ catch (err) {
186
+ // `call()` only ever rejects with an Error.
187
+ console.warn(`Could not destroy the local search index for mailbox ${mailboxUid}: ${err.message}`);
188
+ return false;
68
189
  }
69
190
  finally {
70
191
  initializedMailboxes.delete(mailboxUid);
71
192
  }
72
193
  }
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)));
194
+ /** How long sign-out waits for local index destruction before navigating anyway. */
195
+ export const DESTROY_ALL_TIMEOUT_MS = 3000;
196
+ /**
197
+ * Destroys **every** local index on this device - not just the mailboxes this page load opened (sign-out
198
+ * from Calendar, say, never opened any), by enumerating OPFS for the index directory prefix - and tells every
199
+ * other tab to close its own connections. Closes this tab's own open connections through the Worker first
200
+ * (only if one was ever spawned - there's nothing to close otherwise, and booting one just to delete files
201
+ * would be waste), then removes the directories. Anything left behind is retried on the next load. Resolves
202
+ * `true` when everything was removed within `timeoutMs`; never rejects.
203
+ */
204
+ export async function destroyAllLocalIndexes(timeoutMs = DESTROY_ALL_TIMEOUT_MS) {
205
+ signedOut = true;
206
+ writePendingDeletions([ALL_INDEXES]);
207
+ try {
208
+ getChannel()?.postMessage({ type: "sign-out" });
209
+ }
210
+ catch {
211
+ // A closed/unsupported channel only loses the cross-tab notification.
212
+ }
213
+ const work = (async () => {
214
+ let ok = true;
215
+ if (worker) {
216
+ const { failed } = await call("destroyAll", { generation: nextLocalIndexGeneration() }).catch(() => ({
217
+ failed: ["(worker)"],
218
+ }));
219
+ ok = failed.length === 0;
220
+ }
221
+ initializedMailboxes.clear();
222
+ const { failed } = await removeLocalIndexDirectories();
223
+ writePendingDeletions(failed);
224
+ return ok && failed.length === 0;
225
+ })().catch(() => false);
226
+ let timer;
227
+ const timeout = new Promise((resolve) => {
228
+ timer = setTimeout(() => resolve(false), timeoutMs);
229
+ });
230
+ const result = await Promise.race([work, timeout]);
231
+ clearTimeout(timer);
232
+ if (!result) {
233
+ console.warn("Could not destroy every local search index before signing out.");
234
+ }
235
+ return result;
236
+ }
237
+ /** Removes local indexes for mailboxes outside `accessibleMailboxUids` - e.g. left behind by a different
238
+ * user who signed in on this device without signing out. Never rejects. */
239
+ export async function pruneInaccessibleLocalIndexes(accessibleMailboxUids) {
240
+ await removeLocalIndexDirectories(new Set(accessibleMailboxUids)).catch(() => undefined);
78
241
  }
@@ -14,8 +14,11 @@
14
14
  * takes.
15
15
  */
16
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;
17
+ * with in place. A mismatch deletes and recreates the whole database file (not just its rows), so
18
+ * creation-time-only settings like `auto_vacuum` also apply to upgraded indexes.
19
+ *
20
+ * 2 - added `entities.entity_version` (incremental rebuild skip) and `auto_vacuum=INCREMENTAL`. */
21
+ export const SCHEMA_VERSION = 2;
19
22
  /**
20
23
  * `entities` is the real row store (metadata + the plaintext content fields), `entities_fts` is an FTS5
21
24
  * *external content* table over it (`content='entities'`) - the standard SQLite pattern for keeping one
@@ -49,7 +52,8 @@ CREATE TABLE IF NOT EXISTS entities (
49
52
  subject TEXT,
50
53
  body TEXT,
51
54
  attachment_text TEXT,
52
- byte_size INTEGER NOT NULL DEFAULT 0
55
+ byte_size INTEGER NOT NULL DEFAULT 0,
56
+ entity_version TEXT
53
57
  );
54
58
  CREATE INDEX IF NOT EXISTS idx_entities_date ON entities(date_for_sort);
55
59
  CREATE INDEX IF NOT EXISTS idx_entities_mailbox ON entities(mailbox_uid);
@@ -84,12 +88,13 @@ export const BM25_WEIGHTS_SQL = "3.0, 2.0, 1.0, 1.0";
84
88
  * so re-indexing an already-present message (a flag changed, a folder move) updates it in place and the
85
89
  * `entities_au` trigger keeps `entities_fts` in sync automatically. */
86
90
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
91
+ INSERT INTO entities (entity_type, entity_uid, mailbox_uid, folder_uid, date_for_sort, participants, flags, has_attachments, subject, body, attachment_text, byte_size, entity_version)
92
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
89
93
  ON CONFLICT(entity_uid) DO UPDATE SET
90
94
  folder_uid = excluded.folder_uid, date_for_sort = excluded.date_for_sort, participants = excluded.participants,
91
95
  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
96
+ body = excluded.body, attachment_text = excluded.attachment_text, byte_size = excluded.byte_size,
97
+ entity_version = excluded.entity_version
93
98
  `;
94
99
  /** Bind values for `UPSERT_ENTITY_SQL`, in column order - kept alongside it so the two can never drift
95
100
  * out of sync with each other. */
@@ -107,6 +112,7 @@ export function entityBindValues(entity) {
107
112
  entity.body ?? null,
108
113
  entity.attachmentText ?? null,
109
114
  entity.byteSize,
115
+ entity.entityVersion ?? null,
110
116
  ];
111
117
  }
112
118
  /**
@@ -0,0 +1,86 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The Tier 2 local index's on-disk naming and bulk-removal helpers, shared by the Worker
7
+ * (`localIndexWorker.ts`) and the main thread (`localIndexRpcClient.ts`). Deliberately free of any
8
+ * wa-sqlite import so the main thread can use it without pulling the WASM build into its own bundle.
9
+ *
10
+ * Every mailbox's index lives in its own top-level OPFS directory named `rapidmx-localsearch-<mailboxUid>`
11
+ * (`AccessHandlePoolVFS` creates it from the VFS name). Enumerating that prefix is how sign-out destroys
12
+ * *every* index on this device - including ones for mailboxes this page load never opened, which the
13
+ * RPC client's own session-scoped bookkeeping can't know about - and how a sign-in prunes indexes left
14
+ * behind for mailboxes the current user can no longer access.
15
+ */
16
+ export const LOCAL_INDEX_POOL_PREFIX = "rapidmx-localsearch-";
17
+ /** The OPFS directory name (and `EncryptingVFS` name) a mailbox's index lives under. */
18
+ export function poolNameFor(mailboxUid) {
19
+ return `${LOCAL_INDEX_POOL_PREFIX}${mailboxUid}`;
20
+ }
21
+ /** Inverse of `poolNameFor()`; `undefined` for any OPFS entry that isn't a local index directory. */
22
+ export function mailboxUidFromPoolName(name) {
23
+ return name.startsWith(LOCAL_INDEX_POOL_PREFIX) && name.length > LOCAL_INDEX_POOL_PREFIX.length
24
+ ? name.slice(LOCAL_INDEX_POOL_PREFIX.length)
25
+ : undefined;
26
+ }
27
+ /** `navigator.storage.getDirectory()`, or `undefined` where OPFS isn't available at all (older browsers,
28
+ * jsdom/node) - there is nothing on disk to remove in that case. */
29
+ async function getOpfsRoot() {
30
+ const storage = typeof navigator === "undefined" ? undefined : navigator.storage;
31
+ if (!storage?.getDirectory) {
32
+ return undefined;
33
+ }
34
+ return storage.getDirectory();
35
+ }
36
+ function isNotFound(err) {
37
+ return err?.name === "NotFoundError";
38
+ }
39
+ /** Removes one mailbox's index directory. Resolves quietly when it doesn't exist; rejects for any other
40
+ * failure (most commonly `NoModificationAllowedError` - another tab still holds its access handles). */
41
+ export async function removeLocalIndexDirectory(mailboxUid) {
42
+ const root = await getOpfsRoot();
43
+ if (!root) {
44
+ return;
45
+ }
46
+ try {
47
+ await root.removeEntry(poolNameFor(mailboxUid), { recursive: true });
48
+ }
49
+ catch (err) {
50
+ if (!isNotFound(err)) {
51
+ throw err;
52
+ }
53
+ }
54
+ }
55
+ /** Every mailbox uid that has a local index directory on this origin. */
56
+ export async function listLocalIndexMailboxUids() {
57
+ const root = await getOpfsRoot();
58
+ if (!root) {
59
+ return [];
60
+ }
61
+ const mailboxUids = [];
62
+ // `keys()` is part of the OPFS spec and every browser that has `getDirectory()` at all, but isn't in
63
+ // this TypeScript version's DOM lib yet.
64
+ for await (const name of root.keys()) {
65
+ const mailboxUid = mailboxUidFromPoolName(name);
66
+ if (mailboxUid) {
67
+ mailboxUids.push(mailboxUid);
68
+ }
69
+ }
70
+ return mailboxUids;
71
+ }
72
+ /** Removes every local index directory on this origin except those whose mailbox uid is in `keep`. */
73
+ export async function removeLocalIndexDirectories(keep = new Set()) {
74
+ const result = { removed: [], failed: [] };
75
+ const mailboxUids = (await listLocalIndexMailboxUids()).filter((mailboxUid) => !keep.has(mailboxUid));
76
+ for (const mailboxUid of mailboxUids) {
77
+ try {
78
+ await removeLocalIndexDirectory(mailboxUid);
79
+ result.removed.push(mailboxUid);
80
+ }
81
+ catch {
82
+ result.failed.push(mailboxUid);
83
+ }
84
+ }
85
+ return result;
86
+ }
@@ -9,7 +9,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
- var _EncryptingVFS_instances, _EncryptingVFS_inner, _EncryptingVFS_key, _EncryptingVFS_filenamesByFileId, _EncryptingVFS_readBlock, _EncryptingVFS_writeBlock;
12
+ var _EncryptingVFS_instances, _EncryptingVFS_inner, _EncryptingVFS_key, _EncryptingVFS_filenamesByFileId, _EncryptingVFS_readBlock, _EncryptingVFS_writeBlock, _EncryptingVFS_jReadBlocks, _EncryptingVFS_jWriteBlocks;
13
13
  ///////////////////////////////////////////////////////////////////////////////
14
14
  // Copyright (C) 2026 Jean-Philippe Steinmetz
15
15
  // SPDX-License-Identifier: MPL-2.0
@@ -80,6 +80,10 @@ export class EncryptingVFS extends FacadeVFS {
80
80
  * open handle, never persisted - this map lets `jRead`/`jWrite` recover the stable filename an AAD
81
81
  * needs to bind to, from the ephemeral fileId SQLite actually passes them. */
82
82
  _EncryptingVFS_filenamesByFileId.set(this, new Map());
83
+ /** Set once any block has failed to decrypt/authenticate (or the inner VFS failed a read/write outright)
84
+ * - `localIndexWorker.ts` checks this after a failed SQLite call to tell real corruption (discard and
85
+ * rebuild, spec §11 "Invalidation") apart from an ordinary error such as a malformed FTS5 query. */
86
+ this.corruptionDetected = false;
83
87
  __classPrivateFieldSet(this, _EncryptingVFS_inner, inner, "f");
84
88
  }
85
89
  /** `rawKey` MUST be exactly 32 bytes (AES-256) - see `localIndexKey.ts`'s `deriveLocalIndexKey()`,
@@ -152,50 +156,31 @@ export class EncryptingVFS extends FacadeVFS {
152
156
  const blocks = Math.ceil(iSize / LOGICAL_BLOCK_SIZE);
153
157
  return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jTruncate(pFile, blocks * PHYSICAL_BLOCK_SIZE);
154
158
  }
159
+ /**
160
+ * `jRead`/`jWrite` MUST NOT throw: with the Asyncify build, an exception escaping an async VFS method
161
+ * never reaches the awaiting `sqlite3.*` call - it surfaces only as an unhandled rejection and the
162
+ * SQLite call hangs forever (confirmed by direct reproduction in node against the real wa-sqlite build).
163
+ * A thrown `PageCorruptedError` therefore used to wedge the whole connection instead of triggering a
164
+ * rebuild. Errors are converted to an I/O error return code here, which SQLite does propagate as a
165
+ * normal `SQLiteError`, and recorded on `corruptionDetected`.
166
+ */
155
167
  async jRead(pFile, pData, iOffset) {
156
- const filename = __classPrivateFieldGet(this, _EncryptingVFS_filenamesByFileId, "f").get(pFile) ?? `(unknown:${pFile})`;
157
- const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
158
- const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
159
- let anyAbsent = false;
160
- for (let block = startBlock; block <= endBlock; block++) {
161
- const { plaintext, absent } = await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_readBlock).call(this, pFile, filename, block);
162
- const blockStart = block * LOGICAL_BLOCK_SIZE;
163
- const copyStart = Math.max(iOffset, blockStart);
164
- const copyEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
165
- pData.set(plaintext.subarray(copyStart - blockStart, copyEnd - blockStart), copyStart - iOffset);
166
- // SQLITE_IOERR_SHORT_READ is how SQLite distinguishes "nothing here yet" from "here is real
167
- // (possibly zero-filled) data," e.g. when probing whether a file exists at all - see
168
- // #readBlock's own doc comment on why this is tracked explicitly, not inferred from content.
169
- if (absent) {
170
- anyAbsent = true;
171
- }
168
+ try {
169
+ return await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_jReadBlocks).call(this, pFile, pData, iOffset);
170
+ }
171
+ catch {
172
+ this.corruptionDetected = true;
173
+ return VFS.SQLITE_IOERR_READ;
172
174
  }
173
- return anyAbsent ? VFS.SQLITE_IOERR_SHORT_READ : VFS.SQLITE_OK;
174
175
  }
175
176
  async jWrite(pFile, pData, iOffset) {
176
- const filename = __classPrivateFieldGet(this, _EncryptingVFS_filenamesByFileId, "f").get(pFile) ?? `(unknown:${pFile})`;
177
- const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
178
- const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
179
- for (let block = startBlock; block <= endBlock; block++) {
180
- const blockStart = block * LOGICAL_BLOCK_SIZE;
181
- const writeStart = Math.max(iOffset, blockStart);
182
- const writeEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
183
- // Whole-block write (the common case once SQLite's page size is fixed): no need to read the
184
- // old block first. Anything narrower (a sub-page write, or this block only partially
185
- // overlaps the requested range) needs the existing content as a base - a real
186
- // read-modify-write - since the physical block is re-encrypted as a single AEAD unit.
187
- const plaintext = writeStart === blockStart && writeEnd === blockStart + LOGICAL_BLOCK_SIZE
188
- ? pData.subarray(writeStart - iOffset, writeEnd - iOffset)
189
- : await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_readBlock).call(this, pFile, filename, block).then(({ plaintext: existing }) => {
190
- const merged = existing.slice();
191
- merged.set(pData.subarray(writeStart - iOffset, writeEnd - iOffset), writeStart - blockStart);
192
- return merged;
193
- });
194
- const rc = await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_writeBlock).call(this, pFile, filename, block, plaintext);
195
- if (rc !== VFS.SQLITE_OK)
196
- return rc;
177
+ try {
178
+ return await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_jWriteBlocks).call(this, pFile, pData, iOffset);
179
+ }
180
+ catch {
181
+ this.corruptionDetected = true;
182
+ return VFS.SQLITE_IOERR_WRITE;
197
183
  }
198
- return VFS.SQLITE_OK;
199
184
  }
200
185
  }
201
186
  _EncryptingVFS_inner = new WeakMap(), _EncryptingVFS_key = new WeakMap(), _EncryptingVFS_filenamesByFileId = new WeakMap(), _EncryptingVFS_instances = new WeakSet(), _EncryptingVFS_readBlock =
@@ -227,4 +212,47 @@ async function _EncryptingVFS_readBlock(pFile, filename, blockIndex) {
227
212
  }, _EncryptingVFS_writeBlock = async function _EncryptingVFS_writeBlock(pFile, filename, blockIndex, plaintext) {
228
213
  const physical = await encryptBlock(__classPrivateFieldGet(this, _EncryptingVFS_key, "f"), filename, blockIndex, plaintext);
229
214
  return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jWrite(pFile, physical, blockIndex * PHYSICAL_BLOCK_SIZE);
215
+ }, _EncryptingVFS_jReadBlocks = async function _EncryptingVFS_jReadBlocks(pFile, pData, iOffset) {
216
+ const filename = __classPrivateFieldGet(this, _EncryptingVFS_filenamesByFileId, "f").get(pFile) ?? `(unknown:${pFile})`;
217
+ const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
218
+ const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
219
+ let anyAbsent = false;
220
+ for (let block = startBlock; block <= endBlock; block++) {
221
+ const { plaintext, absent } = await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_readBlock).call(this, pFile, filename, block);
222
+ const blockStart = block * LOGICAL_BLOCK_SIZE;
223
+ const copyStart = Math.max(iOffset, blockStart);
224
+ const copyEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
225
+ pData.set(plaintext.subarray(copyStart - blockStart, copyEnd - blockStart), copyStart - iOffset);
226
+ // SQLITE_IOERR_SHORT_READ is how SQLite distinguishes "nothing here yet" from "here is real
227
+ // (possibly zero-filled) data," e.g. when probing whether a file exists at all - see
228
+ // #readBlock's own doc comment on why this is tracked explicitly, not inferred from content.
229
+ if (absent) {
230
+ anyAbsent = true;
231
+ }
232
+ }
233
+ return anyAbsent ? VFS.SQLITE_IOERR_SHORT_READ : VFS.SQLITE_OK;
234
+ }, _EncryptingVFS_jWriteBlocks = async function _EncryptingVFS_jWriteBlocks(pFile, pData, iOffset) {
235
+ const filename = __classPrivateFieldGet(this, _EncryptingVFS_filenamesByFileId, "f").get(pFile) ?? `(unknown:${pFile})`;
236
+ const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
237
+ const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
238
+ for (let block = startBlock; block <= endBlock; block++) {
239
+ const blockStart = block * LOGICAL_BLOCK_SIZE;
240
+ const writeStart = Math.max(iOffset, blockStart);
241
+ const writeEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
242
+ // Whole-block write (the common case once SQLite's page size is fixed): no need to read the
243
+ // old block first. Anything narrower (a sub-page write, or this block only partially
244
+ // overlaps the requested range) needs the existing content as a base - a real
245
+ // read-modify-write - since the physical block is re-encrypted as a single AEAD unit.
246
+ const plaintext = writeStart === blockStart && writeEnd === blockStart + LOGICAL_BLOCK_SIZE
247
+ ? pData.subarray(writeStart - iOffset, writeEnd - iOffset)
248
+ : await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_readBlock).call(this, pFile, filename, block).then(({ plaintext: existing }) => {
249
+ const merged = existing.slice();
250
+ merged.set(pData.subarray(writeStart - iOffset, writeEnd - iOffset), writeStart - blockStart);
251
+ return merged;
252
+ });
253
+ const rc = await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_writeBlock).call(this, pFile, filename, block, plaintext);
254
+ if (rc !== VFS.SQLITE_OK)
255
+ return rc;
256
+ }
257
+ return VFS.SQLITE_OK;
230
258
  };