@rapidmx/web-client 0.35.0 → 0.37.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 (268) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/calendarReminders.ts +35 -0
  3. package/apps/shared/calendar/sealedEvents.ts +113 -0
  4. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  5. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  6. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +89 -3
  7. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  8. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  9. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  10. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  11. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  12. package/apps/shared/components/calendar/eventForm.ts +2 -0
  13. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  15. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  16. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  17. package/apps/shared/components/contacts/contactCardData.ts +8 -4
  18. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  19. package/apps/shared/components/layout/AppShell.tsx +34 -5
  20. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +30 -1
  21. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
  22. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  23. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  24. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  25. package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
  26. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  27. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  28. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  29. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
  31. package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
  32. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  33. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  34. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  35. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  36. package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
  37. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  38. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  39. package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
  40. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  41. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  42. package/apps/shared/mail/decryptedMessages.ts +11 -3
  43. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  44. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  45. package/apps/shared/mail/reportNotices.ts +3 -1
  46. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  47. package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
  48. package/apps/shared/search/localIndexBuilder.ts +57 -20
  49. package/apps/shared/search/localIndexRpcClient.ts +96 -12
  50. package/apps/shared/search/localIndexSchema.ts +20 -4
  51. package/apps/shared/search/localIndexTransport.ts +6 -2
  52. package/apps/shared/search/localIndexWorker.ts +37 -18
  53. package/apps/shared/search/searchTier2.ts +5 -0
  54. package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
  55. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  56. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  57. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  58. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  59. package/apps/www/_shell.tsx +2 -0
  60. package/apps/www/calendar/index.tsx +225 -61
  61. package/apps/www/contacts/index.tsx +67 -11
  62. package/apps/www/index.tsx +19 -3
  63. package/apps/www/settings/encryption/index.tsx +21 -1
  64. package/apps/www/settings/privacy/index.tsx +89 -39
  65. package/apps/www/tasks/index.tsx +24 -6
  66. package/dist/apps/escrow/matters/[uid].js +2 -2
  67. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  68. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  69. package/dist/apps/shared/calendar/sealedEvents.d.ts +14 -0
  70. package/dist/apps/shared/calendar/sealedEvents.js +85 -0
  71. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  72. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  73. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  74. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +25 -2
  75. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  76. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  77. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  78. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  79. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  80. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  81. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  82. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  83. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  84. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  85. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  86. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  87. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  88. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  89. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  90. package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
  91. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  92. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  93. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  94. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  95. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +27 -2
  96. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
  97. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
  98. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  99. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  100. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  101. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  102. package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
  103. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  104. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  105. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  106. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  107. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  108. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  109. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
  110. package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
  111. package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
  112. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  113. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  114. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  115. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  116. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  117. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  118. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  119. package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
  120. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  121. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  122. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  123. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  124. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
  125. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
  126. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  127. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  128. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  129. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  130. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  131. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  132. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  133. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  134. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  135. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  136. package/dist/apps/shared/mail/reportNotices.js +3 -1
  137. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  138. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  139. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  140. package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
  141. package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
  142. package/dist/apps/shared/search/localIndexBuilder.js +50 -17
  143. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  144. package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
  145. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  146. package/dist/apps/shared/search/localIndexSchema.js +18 -4
  147. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
  148. package/dist/apps/shared/search/localIndexWorker.d.ts +11 -0
  149. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  150. package/dist/apps/shared/search/searchTier2.js +5 -0
  151. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +102 -0
  152. package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
  153. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  154. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  155. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  156. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  157. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  158. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  159. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  160. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  161. package/dist/apps/www/_shell.d.ts +1 -1
  162. package/dist/apps/www/_shell.js +2 -2
  163. package/dist/apps/www/calendar/index.js +88 -20
  164. package/dist/apps/www/contacts/index.js +64 -11
  165. package/dist/apps/www/index.js +19 -3
  166. package/dist/apps/www/settings/encryption/index.js +16 -4
  167. package/dist/apps/www/settings/privacy/index.js +47 -6
  168. package/dist/apps/www/tasks/index.js +21 -7
  169. package/dist/lib/admin/domainsApi.d.ts +1 -1
  170. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  171. package/dist/lib/calendar/calendarApi.d.ts +52 -6
  172. package/dist/lib/calendar/calendarApi.js +110 -17
  173. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  174. package/dist/lib/calendar/calendarMutations.js +73 -14
  175. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  176. package/dist/lib/calendar/inviteApi.js +86 -7
  177. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  178. package/dist/lib/calendar/protectedEvents.js +113 -0
  179. package/dist/lib/calendar/recurrence.d.ts +2 -0
  180. package/dist/lib/calendar/recurrence.js +16 -0
  181. package/dist/lib/calendar/sealedInvites.d.ts +9 -0
  182. package/dist/lib/calendar/sealedInvites.js +65 -0
  183. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  184. package/dist/lib/contacts/contactsApi.js +23 -7
  185. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  186. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  187. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  188. package/dist/lib/crypto/composeSecurity.js +13 -5
  189. package/dist/lib/crypto/keyExport.d.ts +54 -0
  190. package/dist/lib/crypto/keyExport.js +161 -0
  191. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  192. package/dist/lib/crypto/keyPairMatch.js +65 -0
  193. package/dist/lib/crypto/keySession.d.ts +64 -3
  194. package/dist/lib/crypto/keySession.js +179 -12
  195. package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
  196. package/dist/lib/crypto/keyvaultApi.js +45 -3
  197. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  198. package/dist/lib/crypto/meetingKeys.js +87 -0
  199. package/dist/lib/crypto/messageSecurity.d.ts +56 -3
  200. package/dist/lib/crypto/messageSecurity.js +201 -13
  201. package/dist/lib/crypto/mime.d.ts +5 -0
  202. package/dist/lib/crypto/mime.js +25 -0
  203. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  204. package/dist/lib/crypto/openPgpKey.js +89 -0
  205. package/dist/lib/crypto/openpgp.d.ts +47 -0
  206. package/dist/lib/crypto/openpgp.js +88 -0
  207. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  208. package/dist/lib/crypto/pgpMessage.js +271 -0
  209. package/dist/lib/crypto/sharedMailboxKeys.d.ts +62 -0
  210. package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
  211. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  212. package/dist/lib/crypto/smimeMessage.js +11 -7
  213. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  214. package/dist/lib/crypto/zeroAccessKey.js +71 -0
  215. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  216. package/dist/lib/mail/mailApi.d.ts +63 -2
  217. package/dist/lib/mail/mailApi.js +5 -1
  218. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  219. package/dist/lib/mail/mailboxImportApi.js +15 -0
  220. package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
  221. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  222. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  223. package/dist/lib/tasks/tasksApi.js +10 -4
  224. package/dist/lib/util/clientPlatform.d.ts +14 -0
  225. package/dist/lib/util/clientPlatform.js +26 -0
  226. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  227. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  228. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  229. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  230. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  231. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  232. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  233. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  234. package/lib/admin/domainsApi.ts +1 -1
  235. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  236. package/lib/calendar/calendarApi.ts +143 -17
  237. package/lib/calendar/calendarMutations.ts +79 -12
  238. package/lib/calendar/inviteApi.ts +101 -8
  239. package/lib/calendar/protectedEvents.ts +143 -0
  240. package/lib/calendar/recurrence.ts +17 -0
  241. package/lib/calendar/sealedInvites.ts +73 -0
  242. package/lib/contacts/contactsApi.ts +43 -17
  243. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  244. package/lib/crypto/composeSecurity.ts +20 -5
  245. package/lib/crypto/keyExport.ts +229 -0
  246. package/lib/crypto/keyPairMatch.ts +69 -0
  247. package/lib/crypto/keySession.ts +216 -12
  248. package/lib/crypto/keyvaultApi.ts +174 -10
  249. package/lib/crypto/meetingKeys.ts +118 -0
  250. package/lib/crypto/messageSecurity.ts +284 -16
  251. package/lib/crypto/mime.ts +26 -0
  252. package/lib/crypto/openPgpKey.ts +118 -0
  253. package/lib/crypto/openpgp.ts +115 -0
  254. package/lib/crypto/pgpMessage.ts +336 -0
  255. package/lib/crypto/sharedMailboxKeys.ts +402 -0
  256. package/lib/crypto/smimeMessage.ts +11 -7
  257. package/lib/crypto/zeroAccessKey.ts +102 -0
  258. package/lib/mail/conversationsApi.ts +3 -0
  259. package/lib/mail/mailApi.ts +60 -3
  260. package/lib/mail/mailboxImportApi.ts +24 -0
  261. package/lib/search/zeroAccessIndexApi.ts +123 -0
  262. package/lib/tasks/tasksApi.ts +19 -5
  263. package/lib/util/clientPlatform.ts +50 -0
  264. package/lib/videoconf/protectedMeetings.ts +221 -0
  265. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  266. package/lib/zeroAccess/sealedMoves.ts +79 -0
  267. package/lib/zeroAccess/sealedRecords.ts +153 -0
  268. package/package.json +14 -5
@@ -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,19 @@ 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): 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 } satisfies MoveEntityParams).catch(() => undefined);
271
+ await call("moveEntity", {
272
+ mailboxUid,
273
+ entityUid,
274
+ folderUid,
275
+ ...(entityVersion !== undefined ? { entityVersion } : {}),
276
+ ...(flags !== undefined ? { flags } : {}),
277
+ } satisfies MoveEntityParams).catch(
278
+ () => undefined,
279
+ );
259
280
  }
260
281
 
261
282
  function getIndexedVersionsImpl(mailboxUid: string, entityUids: string[]): Promise<Record<string, string>> {
@@ -288,6 +309,9 @@ function setLocalIndexBuildingImpl(mailboxUid: string, building: boolean, comple
288
309
  * when the index could not be removed, e.g. because another tab still has it open; the deletion is then
289
310
  * retried on the next page load. */
290
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);
291
315
  updatePendingDeletions((current) => current.add(mailboxUid));
292
316
  try {
293
317
  await call("destroy", { mailboxUid, generation: nextLocalIndexGenerationImpl() } satisfies DestroyParams);
@@ -315,6 +339,9 @@ export const DESTROY_ALL_TIMEOUT_MS = 3_000;
315
339
  */
316
340
  async function destroyAllLocalIndexesImpl(timeoutMs: number): Promise<boolean> {
317
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();
318
345
  writePendingDeletions([ALL_INDEXES]);
319
346
  try {
320
347
  getChannel()?.postMessage({ type: "sign-out" });
@@ -347,9 +374,13 @@ async function destroyAllLocalIndexesImpl(timeoutMs: number): Promise<boolean> {
347
374
  }
348
375
 
349
376
  /** Removes local indexes for mailboxes outside `accessibleMailboxUids` - e.g. left behind by a different
350
- * 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. */
351
380
  async function pruneInaccessibleLocalIndexesImpl(accessibleMailboxUids: Iterable<string>): Promise<void> {
352
- await removeLocalIndexDirectories(new Set(accessibleMailboxUids)).catch(() => undefined);
381
+ const accessible = new Set(accessibleMailboxUids);
382
+ forgetZeroAccessSyncExcept(accessible);
383
+ await removeLocalIndexDirectories(accessible).catch(() => undefined);
353
384
  }
354
385
 
355
386
  /**
@@ -394,8 +425,49 @@ export function nextLocalIndexGeneration(): number {
394
425
  return currentTransport.nextGeneration();
395
426
  }
396
427
 
397
- export function initLocalIndex(params: InitParams): Promise<void> {
398
- 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);
399
471
  }
400
472
 
401
473
  export function indexLocalEntities(mailboxUid: string, entities: LocalIndexEntity[], generation?: number): Promise<IndexEntitiesResult> {
@@ -409,9 +481,15 @@ export function removeLocalEntity(mailboxUid: string, entityUid: string): Promis
409
481
  }
410
482
 
411
483
  /** Re-points one indexed message at its new folder (archive, cancel-scheduled-send) so `folder:`-scoped
412
- * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntity()`. */
413
- export function moveLocalEntity(mailboxUid: string, entityUid: string, folderUid: string): Promise<void> {
414
- return currentTransport.moveEntity(mailboxUid, entityUid, folderUid);
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
+ }
490
+ return entityVersion !== undefined
491
+ ? currentTransport.moveEntity(mailboxUid, entityUid, folderUid, entityVersion)
492
+ : currentTransport.moveEntity(mailboxUid, entityUid, folderUid);
415
493
  }
416
494
 
417
495
  export function getIndexedVersions(mailboxUid: string, entityUids: string[]): Promise<Record<string, string>> {
@@ -448,19 +526,25 @@ export function setLocalIndexBuilding(mailboxUid: string, building: boolean, com
448
526
  * resolves `false` when the index could not be removed - see the current transport's own doc comment for what
449
527
  * that means for it (the Worker transport also logs why, and retries on the next page load). */
450
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);
451
532
  return currentTransport.destroy(mailboxUid);
452
533
  }
453
534
 
454
535
  /** Destroys **every** local index this transport knows of - not just the mailboxes this session opened - e.g.
455
536
  * on sign-out. Never rejects. Resolves `true` only if everything was removed within `timeoutMs`. */
456
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.
457
540
  return currentTransport.destroyAll(timeoutMs);
458
541
  }
459
542
 
460
543
  /** Removes local indexes for mailboxes outside `accessibleMailboxUids` - e.g. left behind by a different
461
- * 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. */
462
546
  export function pruneInaccessibleLocalIndexes(accessibleMailboxUids: Iterable<string>): Promise<void> {
463
- return currentTransport.pruneInaccessible(accessibleMailboxUids);
547
+ return currentTransport.pruneInaccessible(new Set(accessibleMailboxUids));
464
548
  }
465
549
 
466
550
  /** Retries every deletion an earlier session couldn't finish. Never rejects. */
@@ -114,17 +114,33 @@ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
123
139
  ON CONFLICT(entity_uid) DO UPDATE SET
124
- folder_uid = excluded.folder_uid, date_for_sort = excluded.date_for_sort, participants = excluded.participants,
125
- flags = excluded.flags, has_attachments = excluded.has_attachments, subject = excluded.subject,
140
+ folder_uid = COALESCE(excluded.folder_uid, entities.folder_uid), date_for_sort = excluded.date_for_sort, participants = excluded.participants,
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,7 +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
- moveEntity(mailboxUid: string, entityUid: string, folderUid: 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>;
63
65
  /** The indexed version marker (`Message.version` + folder) currently stored for each of the given entity uids. */
64
66
  indexedVersions(mailboxUid: string, entityUids: string[]): Promise<Record<string, string>>;
65
67
  /** Removes indexed rows outside `keepEntityUids` (and `since`/`options.folderUids`'s own scope). Returns how many rows were pruned. */
@@ -77,7 +79,9 @@ export interface LocalIndexTransport {
77
79
  /** Destroys every index this transport knows of, e.g. on sign-out. Never rejects; resolves `true` only if
78
80
  * everything was removed within `timeoutMs`. */
79
81
  destroyAll(timeoutMs: number): Promise<boolean>;
80
- /** 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. */
81
85
  pruneInaccessible(accessibleMailboxUids: Iterable<string>): Promise<void>;
82
86
  /** Retries any deletion an earlier session couldn't finish. Never rejects. */
83
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
@@ -124,6 +130,10 @@ export interface MoveEntityParams {
124
130
  mailboxUid: string;
125
131
  entityUid: string;
126
132
  folderUid: string;
133
+ /** Recorded as the row's `entity_version`; cleared (the row then counts as changed) when absent. */
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;
127
137
  }
128
138
 
129
139
  export interface IndexedVersionsParams {
@@ -519,6 +529,7 @@ async function withCurrentConnection<T>(params: GenerationParams & { mailboxUid:
519
529
  async function resetCorruptedConnection(mailboxUid: string, connection: OpenConnection): Promise<void> {
520
530
  console.warn(`localIndexWorker: local search index for mailbox ${mailboxUid} is corrupted; discarding and rebuilding.`);
521
531
  connections.delete(mailboxUid);
532
+ self.postMessage({ type: "reset", mailboxUid } satisfies LocalIndexResetNotice);
522
533
  await closeRawConnection(connection).catch(() => undefined);
523
534
  try {
524
535
  await removeLocalIndexDirectory(mailboxUid);
@@ -635,31 +646,39 @@ async function removeEntity(connection: OpenConnection, entityUid: string): Prom
635
646
  }
636
647
 
637
648
  /** Re-points an indexed message at a new folder in place (archive, cancel-scheduled-send). Clears
638
- * `entity_version` so the next build pass re-validates the row against the server. */
639
- async function moveEntity(connection: OpenConnection, entityUid: string, folderUid: string): Promise<void> {
640
- for await (const stmt of connection.sqlite3.statements(connection.db, "UPDATE entities SET folder_uid = ?, entity_version = NULL WHERE entity_uid = ?")) {
641
- connection.sqlite3.bind_collection(stmt, [folderUid, 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);
642
657
  await connection.sqlite3.step(stmt);
643
658
  }
644
659
  }
645
660
 
661
+ /** How many uids one `indexedVersions()` statement binds. */
662
+ export const INDEXED_VERSIONS_BATCH = 500;
663
+
646
664
  /** `entity_version` for whichever of `entityUids` are already indexed - one batched query per builder page,
647
665
  * so a rebuild can skip re-fetching/decrypting unchanged messages. */
648
666
  async function indexedVersions(connection: OpenConnection, entityUids: string[]): Promise<Record<string, string>> {
649
667
  const versions: Record<string, string> = {};
650
- if (entityUids.length === 0) {
651
- return versions;
652
- }
653
- const placeholders = entityUids.map(() => "?").join(", ");
654
- for await (const stmt of connection.sqlite3.statements(
655
- connection.db,
656
- `SELECT entity_uid, entity_version FROM entities WHERE entity_uid IN (${placeholders})`,
657
- )) {
658
- connection.sqlite3.bind_collection(stmt, entityUids);
659
- while ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
660
- const version = connection.sqlite3.column(stmt, 1) as string | null;
661
- if (version !== null) {
662
- 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
+ }
663
682
  }
664
683
  }
665
684
  }
@@ -871,7 +890,7 @@ export async function handleRequest(method: LocalIndexRequest["method"], params:
871
890
  }
872
891
  case "moveEntity": {
873
892
  const p = params as MoveEntityParams;
874
- return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => moveEntity(c, p.entityUid, p.folderUid)));
893
+ return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => moveEntity(c, p.entityUid, p.folderUid, p.entityVersion, p.flags)));
875
894
  }
876
895
  case "indexedVersions": {
877
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