@rapidmx/web-client 0.36.0 → 0.37.1

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 (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -26,6 +26,11 @@
26
26
  * replacement. `searchTier2.ts` additionally races its own calls against a timeout, since a Worker that's
27
27
  * merely stuck (not crashed) posts no `error` event at all.
28
28
  *
29
+ * **A lock destroys the index**: `initLocalIndex()` subscribes once to the key session (`subscribeKeySession()`); when the keys of
30
+ * a mailbox whose index it opened are destroyed (an idle lock, a manual lock, a sign-out), that mailbox's build is cancelled and its
31
+ * index destroyed (spec section 11), whether or not a mail view (`LocalIndexLifecycle`) is still mounted to notice - after leaving Mail
32
+ * for Calendar, or in a host whose account switch unmounted the shell that opened it (`tauri-client`).
33
+ *
29
34
  * **Swappable transport**: everything above is specific to this one implementation - the Worker/OPFS-backed
30
35
  * default. Every function this module exports below (`initLocalIndex`, `searchLocal`, `destroyLocalIndex`, ...)
31
36
  * is a thin delegator to whichever `LocalIndexTransport` (`localIndexTransport.ts`) is "current," which defaults
@@ -38,6 +43,7 @@
38
43
  * plain exports, not part of the swappable interface - they name this one transport's own cross-tab/localStorage
39
44
  * mechanics, which a different transport has no reason to share.
40
45
  */
46
+ import { subscribeKeySession } from "../../../lib/crypto/keySession.js";
41
47
  import type { ParsedSearchQuery } from "../../../lib/search/queryGrammar.js";
42
48
  import type {
43
49
  Coverage,
@@ -48,6 +54,7 @@ import type {
48
54
  IndexedVersionsParams,
49
55
  InitParams,
50
56
  LocalIndexRequest,
57
+ LocalIndexResetNotice,
51
58
  LocalIndexResponse,
52
59
  LocalSearchPage,
53
60
  MoveEntityParams,
@@ -60,6 +67,7 @@ import type {
60
67
  } from "./localIndexWorker.js";
61
68
  import type { LocalIndexEntity } from "./localIndexSchema.js";
62
69
  import { removeLocalIndexDirectories, removeLocalIndexDirectory } from "./localIndexStorage.js";
70
+ import { forgetAllZeroAccessSync, forgetZeroAccessSync, forgetZeroAccessSyncExcept } from "./zeroAccessSyncState.js";
63
71
  import type { LocalIndexTransport, PruneLocalEntitiesOptions, SetLocalIndexBuildingCompletion } from "./localIndexTransport.js";
64
72
 
65
73
  let worker: Worker | undefined;
@@ -94,7 +102,12 @@ function getWorker(): Worker {
94
102
  // listener's own comment for why that guard matters.
95
103
  const created = new Worker(new URL("./localIndexWorker.js", import.meta.url), { type: "module" });
96
104
  worker = created;
97
- created.addEventListener("message", (event: MessageEvent<LocalIndexResponse>) => {
105
+ created.addEventListener("message", (event: MessageEvent<LocalIndexResponse | LocalIndexResetNotice>) => {
106
+ if ("type" in event.data) {
107
+ // The Worker discarded a corrupted index: what sync passes remembered of it goes too, or a rebuilt one would skip it.
108
+ forgetZeroAccessSync(event.data.mailboxUid);
109
+ return;
110
+ }
98
111
  const entry = pending.get(event.data.id);
99
112
  if (!entry) {
100
113
  return;
@@ -251,11 +264,17 @@ async function removeLocalEntityImpl(mailboxUid: string, entityUid: string): Pro
251
264
 
252
265
  /** Re-points one indexed message at its new folder (archive, cancel-scheduled-send) so `folder:`-scoped
253
266
  * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntityImpl()`. */
254
- async function moveLocalEntityImpl(mailboxUid: string, entityUid: string, folderUid: string, entityVersion?: string): Promise<void> {
267
+ async function moveLocalEntityImpl(mailboxUid: string, entityUid: string, folderUid: string, entityVersion?: string, flags?: string): Promise<void> {
255
268
  if (!initializedMailboxes.has(mailboxUid)) {
256
269
  return;
257
270
  }
258
- await call("moveEntity", { mailboxUid, entityUid, folderUid, ...(entityVersion !== undefined ? { entityVersion } : {}) } satisfies MoveEntityParams).catch(
271
+ await call("moveEntity", {
272
+ mailboxUid,
273
+ entityUid,
274
+ folderUid,
275
+ ...(entityVersion !== undefined ? { entityVersion } : {}),
276
+ ...(flags !== undefined ? { flags } : {}),
277
+ } satisfies MoveEntityParams).catch(
259
278
  () => undefined,
260
279
  );
261
280
  }
@@ -290,6 +309,9 @@ function setLocalIndexBuildingImpl(mailboxUid: string, building: boolean, comple
290
309
  * when the index could not be removed, e.g. because another tab still has it open; the deletion is then
291
310
  * retried on the next page load. */
292
311
  async function destroyLocalIndexImpl(mailboxUid: string): Promise<boolean> {
312
+ // What zero-access sync passes remembered of it goes with it, here too, so a host wrapper destroying mailboxes one by one through this
313
+ // transport (`tauri-client`'s scoped sign-out) forgets theirs.
314
+ forgetZeroAccessSync(mailboxUid);
293
315
  updatePendingDeletions((current) => current.add(mailboxUid));
294
316
  try {
295
317
  await call("destroy", { mailboxUid, generation: nextLocalIndexGenerationImpl() } satisfies DestroyParams);
@@ -317,6 +339,9 @@ export const DESTROY_ALL_TIMEOUT_MS = 3_000;
317
339
  */
318
340
  async function destroyAllLocalIndexesImpl(timeoutMs: number): Promise<boolean> {
319
341
  signedOut = true;
342
+ // Here, in the transport, as for prune: a host wrapper that destroys only some accounts' indexes (`tauri-client`, several accounts in
343
+ // one origin) keeps the other accounts' sync state too.
344
+ forgetAllZeroAccessSync();
320
345
  writePendingDeletions([ALL_INDEXES]);
321
346
  try {
322
347
  getChannel()?.postMessage({ type: "sign-out" });
@@ -349,9 +374,13 @@ async function destroyAllLocalIndexesImpl(timeoutMs: number): Promise<boolean> {
349
374
  }
350
375
 
351
376
  /** Removes local indexes for mailboxes outside `accessibleMailboxUids` - e.g. left behind by a different
352
- * user who signed in on this device without signing out. Never rejects. */
377
+ * user who signed in on this device without signing out - and what zero-access sync passes remembered of them, as on a destroy. Both
378
+ * here, in the transport, so a host's wrapper that keeps other indexes (`tauri-client`, several accounts in one origin) keeps their sync
379
+ * state too. Never rejects. */
353
380
  async function pruneInaccessibleLocalIndexesImpl(accessibleMailboxUids: Iterable<string>): Promise<void> {
354
- await removeLocalIndexDirectories(new Set(accessibleMailboxUids)).catch(() => undefined);
381
+ const accessible = new Set(accessibleMailboxUids);
382
+ forgetZeroAccessSyncExcept(accessible);
383
+ await removeLocalIndexDirectories(accessible).catch(() => undefined);
355
384
  }
356
385
 
357
386
  /**
@@ -396,8 +425,49 @@ export function nextLocalIndexGeneration(): number {
396
425
  return currentTransport.nextGeneration();
397
426
  }
398
427
 
399
- export function initLocalIndex(params: InitParams): Promise<void> {
400
- return currentTransport.init(params);
428
+ /** Every mailbox whose index `initLocalIndex()` opened, through whichever transport, and that wasn't destroyed since. */
429
+ const openedMailboxes = new Set<string>();
430
+ /** How many times each mailbox's keys were destroyed this page load - an `initLocalIndex()` that a lock overtook destroys what it opened. */
431
+ const lockCounts = new Map<string, number>();
432
+ let watchingLocks = false;
433
+
434
+ /** Cancels `mailboxUid`'s build and destroys its index, as `LocalIndexLifecycle` does on a lock. Never rejects. */
435
+ function destroyOnLock(mailboxUid: string): void {
436
+ // Stop a build pass still running with the destroyed keys; the destroy also makes the Worker reject anything that pass still sends.
437
+ // Loaded lazily (it imports this module, and carries the message security code).
438
+ import("./localIndexBuilder.js")
439
+ .then(({ cancelLocalIndexBuild }) => cancelLocalIndexBuild(mailboxUid))
440
+ .catch(() => undefined);
441
+ void destroyLocalIndex(mailboxUid);
442
+ }
443
+
444
+ /** Subscribes, once, to key destruction: see this module's doc comment ("A lock destroys the index"). */
445
+ function watchKeyLocks(): void {
446
+ if (watchingLocks) {
447
+ return;
448
+ }
449
+ watchingLocks = true;
450
+ subscribeKeySession(({ mailboxUid, state }) => {
451
+ if (state !== "locked") {
452
+ return;
453
+ }
454
+ lockCounts.set(mailboxUid, (lockCounts.get(mailboxUid) ?? 0) + 1);
455
+ if (openedMailboxes.has(mailboxUid)) {
456
+ destroyOnLock(mailboxUid);
457
+ }
458
+ });
459
+ }
460
+
461
+ export async function initLocalIndex(params: InitParams): Promise<void> {
462
+ watchKeyLocks();
463
+ const locks = lockCounts.get(params.mailboxUid) ?? 0;
464
+ await currentTransport.init(params);
465
+ if ((lockCounts.get(params.mailboxUid) ?? 0) !== locks) {
466
+ // Its keys were destroyed while it opened: it never counted as open, so the lock's own destroy didn't reach it.
467
+ destroyOnLock(params.mailboxUid);
468
+ return;
469
+ }
470
+ openedMailboxes.add(params.mailboxUid);
401
471
  }
402
472
 
403
473
  export function indexLocalEntities(mailboxUid: string, entities: LocalIndexEntity[], generation?: number): Promise<IndexEntitiesResult> {
@@ -411,8 +481,12 @@ export function removeLocalEntity(mailboxUid: string, entityUid: string): Promis
411
481
  }
412
482
 
413
483
  /** Re-points one indexed message at its new folder (archive, cancel-scheduled-send) so `folder:`-scoped
414
- * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntity()`. */
415
- export function moveLocalEntity(mailboxUid: string, entityUid: string, folderUid: string, entityVersion?: string): Promise<void> {
484
+ * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntity()`. `flags` (`,flag,flag,`) sets the row's flags
485
+ * too - a zero-access row's, which nothing else records. */
486
+ export function moveLocalEntity(mailboxUid: string, entityUid: string, folderUid: string, entityVersion?: string, flags?: string): Promise<void> {
487
+ if (flags !== undefined) {
488
+ return currentTransport.moveEntity(mailboxUid, entityUid, folderUid, entityVersion, flags);
489
+ }
416
490
  return entityVersion !== undefined
417
491
  ? currentTransport.moveEntity(mailboxUid, entityUid, folderUid, entityVersion)
418
492
  : currentTransport.moveEntity(mailboxUid, entityUid, folderUid);
@@ -452,19 +526,25 @@ export function setLocalIndexBuilding(mailboxUid: string, building: boolean, com
452
526
  * resolves `false` when the index could not be removed - see the current transport's own doc comment for what
453
527
  * that means for it (the Worker transport also logs why, and retries on the next page load). */
454
528
  export function destroyLocalIndex(mailboxUid: string): Promise<boolean> {
529
+ openedMailboxes.delete(mailboxUid);
530
+ // What zero-access sync passes remembered importing into it goes with it, whatever the transport.
531
+ forgetZeroAccessSync(mailboxUid);
455
532
  return currentTransport.destroy(mailboxUid);
456
533
  }
457
534
 
458
535
  /** Destroys **every** local index this transport knows of - not just the mailboxes this session opened - e.g.
459
536
  * on sign-out. Never rejects. Resolves `true` only if everything was removed within `timeoutMs`. */
460
537
  export function destroyAllLocalIndexes(timeoutMs = DESTROY_ALL_TIMEOUT_MS): Promise<boolean> {
538
+ // `openedMailboxes` is left as it is: a host transport may keep other accounts' indexes open (`tauri-client`), and a lock of one
539
+ // already destroyed finds nothing to close.
461
540
  return currentTransport.destroyAll(timeoutMs);
462
541
  }
463
542
 
464
543
  /** Removes local indexes for mailboxes outside `accessibleMailboxUids` - e.g. left behind by a different
465
- * user who signed in on this device without signing out. Never rejects. */
544
+ * user who signed in on this device without signing out. The transport decides what goes, zero-access sync state included (the
545
+ * Worker transport forgets it with the index; a host wrapper that skips the prune keeps both). Never rejects. */
466
546
  export function pruneInaccessibleLocalIndexes(accessibleMailboxUids: Iterable<string>): Promise<void> {
467
- return currentTransport.pruneInaccessible(accessibleMailboxUids);
547
+ return currentTransport.pruneInaccessible(new Set(accessibleMailboxUids));
468
548
  }
469
549
 
470
550
  /** Retries every deletion an earlier session couldn't finish. Never rejects. */
@@ -114,9 +114,25 @@ export interface LocalIndexEntity {
114
114
  entityVersion?: string;
115
115
  }
116
116
 
117
+ /** The change marker a message's row stores (`LocalIndexEntity.entityVersion`): a new server `version` (flags, content) or a folder move
118
+ * both invalidate the indexed copy. */
119
+ export function messageRowVersion(message: { version: number; folderUid: string }): string {
120
+ return `${message.version}:${message.folderUid}`;
121
+ }
122
+
123
+ /** A message's set flags as a row stores them. Leading/trailing comma so `flags LIKE '%,x,%'` (`buildSearchPredicates()`) matches
124
+ * correctly even for the first/last flag in the list. */
125
+ export function messageRowFlags(message: { flags: object }): string {
126
+ const set = Object.entries(message.flags as Record<string, unknown>)
127
+ .filter(([, value]) => value)
128
+ .map(([key]) => key);
129
+ return `,${set.join(",")},`;
130
+ }
131
+
117
132
  /** `entity_uid` upsert - `ON CONFLICT` (SQLite's UPSERT syntax) rather than a separate delete-then-insert,
118
133
  * so re-indexing an already-present message (a flag changed, a folder move) updates it in place and the
119
- * `entities_au` trigger keeps `entities_fts` in sync automatically. */
134
+ * `entities_au` trigger keeps `entities_fts` in sync automatically. A row written without a folder, flags or version (a zero-access
135
+ * document, which carries none) keeps the ones recorded: re-importing it must not make it look unrecorded again. */
120
136
  export const UPSERT_ENTITY_SQL = `
121
137
  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)
122
138
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -124,7 +140,7 @@ ON CONFLICT(entity_uid) DO UPDATE SET
124
140
  folder_uid = COALESCE(excluded.folder_uid, entities.folder_uid), date_for_sort = excluded.date_for_sort, participants = excluded.participants,
125
141
  flags = CASE WHEN excluded.flags = '' THEN entities.flags ELSE excluded.flags END, has_attachments = excluded.has_attachments, subject = excluded.subject,
126
142
  body = excluded.body, attachment_text = excluded.attachment_text, byte_size = excluded.byte_size,
127
- entity_version = excluded.entity_version
143
+ entity_version = COALESCE(excluded.entity_version, entities.entity_version)
128
144
  `;
129
145
 
130
146
  /** Bind values for `UPSERT_ENTITY_SQL`, in column order - kept alongside it so the two can never drift
@@ -59,8 +59,9 @@ export interface LocalIndexTransport {
59
59
  /** Drops one indexed entity. */
60
60
  removeEntity(mailboxUid: string, entityUid: string): Promise<void>;
61
61
  /** Re-points one indexed entity at its new folder. */
62
- /** `entityVersion`, when given, is recorded with the folder (a transport that can't record it leaves the row's version cleared). */
63
- moveEntity(mailboxUid: string, entityUid: string, folderUid: string, entityVersion?: string): Promise<void>;
62
+ /** `entityVersion`, when given, is recorded with the folder (a transport that can't record it leaves the row's version cleared), and
63
+ * `flags` replaces the row's flags (a transport that can't record them leaves them as they are). */
64
+ moveEntity(mailboxUid: string, entityUid: string, folderUid: string, entityVersion?: string, flags?: string): Promise<void>;
64
65
  /** The indexed version marker (`Message.version` + folder) currently stored for each of the given entity uids. */
65
66
  indexedVersions(mailboxUid: string, entityUids: string[]): Promise<Record<string, string>>;
66
67
  /** Removes indexed rows outside `keepEntityUids` (and `since`/`options.folderUids`'s own scope). Returns how many rows were pruned. */
@@ -78,7 +79,9 @@ export interface LocalIndexTransport {
78
79
  /** Destroys every index this transport knows of, e.g. on sign-out. Never rejects; resolves `true` only if
79
80
  * everything was removed within `timeoutMs`. */
80
81
  destroyAll(timeoutMs: number): Promise<boolean>;
81
- /** Removes indexes for mailboxes outside `accessibleMailboxUids`. Never rejects. */
82
+ /** Removes indexes for mailboxes outside `accessibleMailboxUids`, and what zero-access sync remembered of each one it removes
83
+ * (`forgetZeroAccessSyncExcept()`; `pruneInaccessibleLocalIndexes()` leaves that to the transport, so a host can keep both). Never
84
+ * rejects. */
82
85
  pruneInaccessible(accessibleMailboxUids: Iterable<string>): Promise<void>;
83
86
  /** Retries any deletion an earlier session couldn't finish. Never rejects. */
84
87
  retryPendingDeletions(): Promise<void>;
@@ -72,6 +72,12 @@ export type LocalIndexResponse =
72
72
  | { id: number; ok: true; result: unknown }
73
73
  | { id: number; ok: false; error: string };
74
74
 
75
+ /** Posted unprompted when a corrupted index was discarded (`resetCorruptedConnection()`), so the main thread forgets what it remembered of it. */
76
+ export interface LocalIndexResetNotice {
77
+ type: "reset";
78
+ mailboxUid: string;
79
+ }
80
+
75
81
  /**
76
82
  * Carried by every call a build pass makes (`init`, `setWindow`, `setBuilding`, `indexEntities`,
77
83
  * `pruneEntities`). Generations come from one monotonic counter on the main thread
@@ -126,6 +132,8 @@ export interface MoveEntityParams {
126
132
  folderUid: string;
127
133
  /** Recorded as the row's `entity_version`; cleared (the row then counts as changed) when absent. */
128
134
  entityVersion?: string;
135
+ /** The row's flags (`,flag,flag,`), set too when given - a zero-access row, whose imported document carries none. */
136
+ flags?: string;
129
137
  }
130
138
 
131
139
  export interface IndexedVersionsParams {
@@ -521,6 +529,7 @@ async function withCurrentConnection<T>(params: GenerationParams & { mailboxUid:
521
529
  async function resetCorruptedConnection(mailboxUid: string, connection: OpenConnection): Promise<void> {
522
530
  console.warn(`localIndexWorker: local search index for mailbox ${mailboxUid} is corrupted; discarding and rebuilding.`);
523
531
  connections.delete(mailboxUid);
532
+ self.postMessage({ type: "reset", mailboxUid } satisfies LocalIndexResetNotice);
524
533
  await closeRawConnection(connection).catch(() => undefined);
525
534
  try {
526
535
  await removeLocalIndexDirectory(mailboxUid);
@@ -637,31 +646,39 @@ async function removeEntity(connection: OpenConnection, entityUid: string): Prom
637
646
  }
638
647
 
639
648
  /** Re-points an indexed message at a new folder in place (archive, cancel-scheduled-send). Clears
640
- * `entity_version` so the next build pass re-validates the row against the server. */
641
- async function moveEntity(connection: OpenConnection, entityUid: string, folderUid: string, entityVersion?: string): Promise<void> {
642
- for await (const stmt of connection.sqlite3.statements(connection.db, "UPDATE entities SET folder_uid = ?, entity_version = ? WHERE entity_uid = ?")) {
643
- connection.sqlite3.bind_collection(stmt, [folderUid, entityVersion ?? null, entityUid]);
649
+ * `entity_version` so the next build pass re-validates the row against the server. Sets its flags too when `flags` is given. */
650
+ async function moveEntity(connection: OpenConnection, entityUid: string, folderUid: string, entityVersion?: string, flags?: string): Promise<void> {
651
+ const [sql, values] =
652
+ flags === undefined
653
+ ? ["UPDATE entities SET folder_uid = ?, entity_version = ? WHERE entity_uid = ?", [folderUid, entityVersion ?? null, entityUid]]
654
+ : ["UPDATE entities SET folder_uid = ?, entity_version = ?, flags = ? WHERE entity_uid = ?", [folderUid, entityVersion ?? null, flags, entityUid]];
655
+ for await (const stmt of connection.sqlite3.statements(connection.db, sql)) {
656
+ connection.sqlite3.bind_collection(stmt, values);
644
657
  await connection.sqlite3.step(stmt);
645
658
  }
646
659
  }
647
660
 
661
+ /** How many uids one `indexedVersions()` statement binds. */
662
+ export const INDEXED_VERSIONS_BATCH = 500;
663
+
648
664
  /** `entity_version` for whichever of `entityUids` are already indexed - one batched query per builder page,
649
665
  * so a rebuild can skip re-fetching/decrypting unchanged messages. */
650
666
  async function indexedVersions(connection: OpenConnection, entityUids: string[]): Promise<Record<string, string>> {
651
667
  const versions: Record<string, string> = {};
652
- if (entityUids.length === 0) {
653
- return versions;
654
- }
655
- const placeholders = entityUids.map(() => "?").join(", ");
656
- for await (const stmt of connection.sqlite3.statements(
657
- connection.db,
658
- `SELECT entity_uid, entity_version FROM entities WHERE entity_uid IN (${placeholders})`,
659
- )) {
660
- connection.sqlite3.bind_collection(stmt, entityUids);
661
- while ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
662
- const version = connection.sqlite3.column(stmt, 1) as string | null;
663
- if (version !== null) {
664
- versions[connection.sqlite3.column(stmt, 0) as string] = version;
668
+ // In statements of at most `INDEXED_VERSIONS_BATCH` uids: SQLite refuses one binding more than its variable limit (32766 here).
669
+ for (let i = 0; i < entityUids.length; i += INDEXED_VERSIONS_BATCH) {
670
+ const batch: string[] = entityUids.slice(i, i + INDEXED_VERSIONS_BATCH);
671
+ const placeholders = batch.map(() => "?").join(", ");
672
+ for await (const stmt of connection.sqlite3.statements(
673
+ connection.db,
674
+ `SELECT entity_uid, entity_version FROM entities WHERE entity_uid IN (${placeholders})`,
675
+ )) {
676
+ connection.sqlite3.bind_collection(stmt, batch);
677
+ while ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
678
+ const version = connection.sqlite3.column(stmt, 1) as string | null;
679
+ if (version !== null) {
680
+ versions[connection.sqlite3.column(stmt, 0) as string] = version;
681
+ }
665
682
  }
666
683
  }
667
684
  }
@@ -873,7 +890,7 @@ export async function handleRequest(method: LocalIndexRequest["method"], params:
873
890
  }
874
891
  case "moveEntity": {
875
892
  const p = params as MoveEntityParams;
876
- return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => moveEntity(c, p.entityUid, p.folderUid, p.entityVersion)));
893
+ return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => moveEntity(c, p.entityUid, p.folderUid, p.entityVersion, p.flags)));
877
894
  }
878
895
  case "indexedVersions": {
879
896
  const p = params as IndexedVersionsParams;
@@ -18,6 +18,7 @@
18
18
  * search* fail just because this one, most novel piece had a bad moment.
19
19
  */
20
20
  import type { UnlockedKeys } from "../../../lib/crypto/keySession.js";
21
+ import { KeysLockedError } from "../../../lib/crypto/masterKey.js";
21
22
  import type { ParsedSearchQuery } from "../../../lib/search/queryGrammar.js";
22
23
  import type { SearchResult } from "../../../lib/search/searchApi.js";
23
24
  import { deriveLocalIndexKey } from "./localIndexKey.js";
@@ -41,6 +42,10 @@ export interface Tier2SearchOutcome {
41
42
  * wins, and the other is a cheap no-op. */
42
43
  async function ensureInitialized(mailboxUid: string, unlocked: UnlockedKeys): Promise<void> {
43
44
  const indexKey = await deriveLocalIndexKey(unlocked.masterKey, mailboxUid);
45
+ // Locked during the derive, which still succeeds: open nothing with the destroyed keys (the lock's own destroy found nothing open).
46
+ if (unlocked.destroyed) {
47
+ throw new KeysLockedError();
48
+ }
44
49
  await initLocalIndex({ mailboxUid, indexKey });
45
50
  }
46
51