@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
@@ -0,0 +1,544 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Keeps this device's local search index (Tier 2) in step with a zero-access mailbox's server index (`specs/search.md` §7-8): the
7
+ * server extracted every message's text at delivery and stores it only encrypted to the mailbox's zero-access key, as one sealed
8
+ * entry per message until a client compacts a closed week into a segment. A sync pass reads the manifest; imports each segment it
9
+ * hasn't imported in its current generation, newest weeks first (the worker's byte budget evicts the oldest); imports the pending entries
10
+ * written since its last pass (the live tail, asked from a little before then, skipping what it has); and compacts a few closed weeks
11
+ * that still have pending entries - it decrypts them and the week's current segment, merges them, seals the result to the mailbox's
12
+ * active zero-access key and uploads it as the next generation. A lost race (`409`) is left to the next pass.
13
+ *
14
+ * A segment (format version 1, `specs/search.md` §16) is gzipped JSON `{ v: 1, documents }` of the same documents the entries carry, so
15
+ * everything is imported through `indexLocalEntities()` - no new storage primitive, on web and on tauri alike.
16
+ *
17
+ * **Client segments (Source C).** End-to-end mail never reaches the server readable, so the server has nothing to index for it: the local
18
+ * index builder (`localIndexBuilder.ts`) decrypts it on one device and uploads what it built with `uploadClientDocuments()`, as `origin:
19
+ * "client"` segments sealed to the same zero-access key, merged into the week's current one. Every device imports them
20
+ * (`importClientSegments()`, run by the builder before it decrypts anything and by each sync pass), so end-to-end mail is decrypted for
21
+ * search once per mailbox rather than once per device - a row imported at the message's current version is not decrypted again.
22
+ *
23
+ * Nothing here ever sends the server anything it can read: entries are decrypted on this device, and a segment is sealed before upload.
24
+ */
25
+ import { fromBase64, toBase64 } from "../../../lib/crypto/encoding.js";
26
+ import { decryptEnvelopedDataWithKeys, encryptForRecipients } from "../../../lib/crypto/smime.js";
27
+ import { getSearchIndexManifest, getSearchIndexSegment, listSearchIndexEntries, putSearchIndexSegment, } from "../../../lib/search/zeroAccessIndexApi.js";
28
+ import { ApiRequestError } from "../../../lib/util/api.js";
29
+ import { clientPlatform, isConstrainedConnection } from "../../../lib/util/clientPlatform.js";
30
+ import { getMessage } from "../../../lib/mail/mailApi.js";
31
+ import { getIndexedVersions, getLocalCoverage, indexLocalEntities, moveLocalEntity } from "./localIndexRpcClient.js";
32
+ import { messageRowFlags, messageRowVersion } from "./localIndexSchema.js";
33
+ import { forgetZeroAccessSync, zeroAccessSyncEpoch, zeroAccessSyncStateKey } from "./zeroAccessSyncState.js";
34
+ export { forgetZeroAccessSync };
35
+ /** The segment format this client writes and reads. */
36
+ export const SEGMENT_SCHEMA_VERSION = 1;
37
+ /** How far before its last pass the live tail asks again, so an entry committed late is never skipped. */
38
+ export const TAIL_OVERLAP_MS = 10 * 60 * 1000;
39
+ /** At most this many weeks are compacted per pass, so a large backlog is worked through over several passes. */
40
+ export const MAX_COMPACTIONS_PER_PASS = 4;
41
+ /** How long after a week's `compactableAt` each platform takes it (restapi `specs/search.md` §8 *Who compacts*): a computer first,
42
+ * a phone only when nothing else has for a day. */
43
+ export const COMPACTION_DELAY_MS = { desktop: 0, web: 6 * 3600000, mobile: 24 * 3600000 };
44
+ /** A mobile client declines a week whose pending entries and current segment together are larger than this. */
45
+ export const MOBILE_COMPACTION_MAX_BYTES = 5 * 1024 * 1024;
46
+ /**
47
+ * Whether this client should compact `week` now: it is compactable, has pending entries, and this platform's delay after its
48
+ * `compactableAt` has passed. A mobile client also declines on a cellular or data-saver connection, and a week larger than
49
+ * `MOBILE_COMPACTION_MAX_BYTES` with `segmentBytes` (its current segment's size) - or of unknown size, or from a server that
50
+ * doesn't say when it became compactable.
51
+ */
52
+ export function shouldCompact(week, segmentBytes, platform = clientPlatform(), now = Date.now(), constrained = isConstrainedConnection()) {
53
+ if (!week.compactable || week.pendingEntries === 0) {
54
+ return false;
55
+ }
56
+ const since = week.compactableAt ? Date.parse(week.compactableAt) : NaN;
57
+ if (platform === "mobile") {
58
+ if (constrained || Number.isNaN(since) || week.pendingBytes === undefined || week.pendingBytes + segmentBytes > MOBILE_COMPACTION_MAX_BYTES) {
59
+ return false;
60
+ }
61
+ }
62
+ // A server that doesn't say when the week became compactable: compactable is all there is to go on.
63
+ return Number.isNaN(since) || since + COMPACTION_DELAY_MS[platform] <= now;
64
+ }
65
+ /** Entities handed to the worker per call. */
66
+ const IMPORT_BATCH = 200;
67
+ /** How many times `uploadClientDocuments()` re-reads a week's client segment after another device replaced it first. */
68
+ export const CLIENT_UPLOAD_ATTEMPTS = 3;
69
+ function loadState(mailboxUid) {
70
+ try {
71
+ const raw = localStorage.getItem(zeroAccessSyncStateKey(mailboxUid));
72
+ const parsed = raw ? JSON.parse(raw) : undefined;
73
+ if (parsed && typeof parsed === "object" && typeof parsed.segments === "object") {
74
+ return parsed;
75
+ }
76
+ }
77
+ catch {
78
+ // Storage unavailable or corrupt: start over, which only costs a re-import.
79
+ }
80
+ return { segments: {} };
81
+ }
82
+ /** Remembers `state` - unless the mailbox's local index was destroyed since the pass began (`epoch`, `zeroAccessSyncEpoch()` then): what
83
+ * the pass imported went with it, and a new index must not be taken for it. */
84
+ function saveState(mailboxUid, state, epoch) {
85
+ if (zeroAccessSyncEpoch(mailboxUid) !== epoch) {
86
+ return;
87
+ }
88
+ try {
89
+ localStorage.setItem(zeroAccessSyncStateKey(mailboxUid), JSON.stringify(state));
90
+ }
91
+ catch {
92
+ // Not remembered: the next pass imports again, which is idempotent (entities are upserted).
93
+ }
94
+ }
95
+ async function transform(bytes, stream) {
96
+ const output = new Blob([bytes]).stream().pipeThrough(stream);
97
+ return new Uint8Array(await new Response(output).arrayBuffer());
98
+ }
99
+ const gunzip = (bytes) => transform(bytes, new DecompressionStream("gzip"));
100
+ const gzip = (bytes) => transform(bytes, new CompressionStream("gzip"));
101
+ /** Decrypts and gunzips `der` with `keys` and parses the JSON inside - `undefined` when no key opens it or it isn't JSON. */
102
+ async function openSealedJson(der, keys) {
103
+ try {
104
+ return JSON.parse(new TextDecoder().decode(await gunzip(await decryptEnvelopedDataWithKeys(der, keys))));
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ }
110
+ function isDocument(value) {
111
+ const doc = value;
112
+ return !!doc && doc.v === 1 && typeof doc.messageUid === "string" && typeof doc.subject === "string" && typeof doc.body === "string";
113
+ }
114
+ /** At most this many messages' metadata are kept per mailbox for rows not imported yet; the oldest go first. */
115
+ export const MAX_REMEMBERED_METADATA = 50000;
116
+ /** Folder, version and flags the builder recorded for zero-access messages, by mailbox and message uid, until their row is imported. */
117
+ const rememberedMetadata = new Map();
118
+ /**
119
+ * Remembers zero-access message `messageUid`'s folder, version and flags for when its row is imported: the builder records them on the row
120
+ * as it walks, which does nothing for a row that isn't there yet (the index started empty - a new device, or after a lock - and this sync
121
+ * imports the rows after the build).
122
+ */
123
+ export function rememberZeroAccessMetadata(mailboxUid, messageUid, metadata) {
124
+ let remembered = rememberedMetadata.get(mailboxUid);
125
+ if (!remembered) {
126
+ remembered = new Map();
127
+ rememberedMetadata.set(mailboxUid, remembered);
128
+ }
129
+ remembered.delete(messageUid);
130
+ remembered.set(messageUid, metadata);
131
+ if (remembered.size > MAX_REMEMBERED_METADATA) {
132
+ remembered.delete(remembered.keys().next().value);
133
+ }
134
+ }
135
+ /** Records on the just-imported rows of `messageUids` what the builder remembered of them, once. */
136
+ async function applyRememberedMetadata(mailboxUid, messageUids) {
137
+ const remembered = rememberedMetadata.get(mailboxUid);
138
+ for (const uid of messageUids) {
139
+ const metadata = remembered?.get(uid);
140
+ if (metadata) {
141
+ await moveLocalEntity(mailboxUid, uid, metadata.folderUid, metadata.entityVersion, metadata.flags);
142
+ remembered.delete(uid);
143
+ }
144
+ }
145
+ }
146
+ /** At most this many live-tail rows a pass looks up (`recordTailMetadata()`); the rest get theirs from the next build pass. */
147
+ export const MAX_TAIL_LOOKUPS = 500;
148
+ /** How many of those lookups run at once. */
149
+ const TAIL_LOOKUP_CONCURRENCY = 4;
150
+ /**
151
+ * Gives the live tail's rows that nothing recorded yet (`docs`: rows the builder hasn't seen - mail that arrived after its walk) their
152
+ * folder, version and flags at import, from the message itself, so a folder or flag filter finds them before the next build pass. At
153
+ * most `MAX_TAIL_LOOKUPS`. Best effort: a message that is gone, moved to another mailbox or won't load is left to the builder.
154
+ */
155
+ async function recordTailMetadata(mailboxUid, messageUids, client) {
156
+ const queue = messageUids.slice(0, MAX_TAIL_LOOKUPS);
157
+ const worker = async () => {
158
+ for (let uid = queue.shift(); uid !== undefined; uid = queue.shift()) {
159
+ try {
160
+ const message = await getMessage(uid, client);
161
+ if (message.mailboxUid === mailboxUid && message.folderUid) {
162
+ await moveLocalEntity(mailboxUid, uid, message.folderUid, messageRowVersion(message), messageRowFlags(message));
163
+ }
164
+ }
165
+ catch {
166
+ // Left to the next build pass.
167
+ }
168
+ }
169
+ };
170
+ await Promise.all(Array.from({ length: Math.min(TAIL_LOOKUP_CONCURRENCY, queue.length) }, worker));
171
+ }
172
+ /** A document as a row of the local index. Folder and flags aren't in the document - the builder records them (`localIndexBuilder.ts`,
173
+ * here through `rememberZeroAccessMetadata()` when the row came later; a live-tail row nothing recorded is looked up,
174
+ * `recordTailMetadata()`), and the upsert keeps what is already there. */
175
+ export function documentEntity(mailboxUid, doc) {
176
+ const participants = (doc.participants ?? []).join(" ");
177
+ const attachmentText = [...(doc.attachmentNames ?? []), ...(doc.attachmentText ?? [])].join("\n");
178
+ return {
179
+ entityType: "message",
180
+ entityUid: doc.messageUid,
181
+ mailboxUid,
182
+ dateForSort: new Date(Number.isFinite(doc.dateForSort) ? doc.dateForSort : 0).toISOString(),
183
+ participants,
184
+ flags: "",
185
+ hasAttachments: (doc.attachmentNames ?? []).length > 0,
186
+ subject: doc.subject,
187
+ body: doc.body,
188
+ attachmentText,
189
+ byteSize: doc.subject.length + doc.body.length + attachmentText.length + participants.length,
190
+ };
191
+ }
192
+ async function importDocuments(mailboxUid, docs) {
193
+ for (let i = 0; i < docs.length; i += IMPORT_BATCH) {
194
+ const batch = docs.slice(i, i + IMPORT_BATCH);
195
+ await indexLocalEntities(mailboxUid, batch.map((doc) => documentEntity(mailboxUid, doc)));
196
+ await applyRememberedMetadata(mailboxUid, batch.map((doc) => doc.messageUid));
197
+ }
198
+ }
199
+ /** The documents of a segment generation that pass `accept`, or `undefined` when it won't open or is a format this client doesn't read. */
200
+ async function readSegmentAs(mailboxUid, segment, keys, accept, client) {
201
+ if (segment.schemaVersion !== SEGMENT_SCHEMA_VERSION) {
202
+ return undefined;
203
+ }
204
+ const bundle = (await openSealedJson(await getSearchIndexSegment(mailboxUid, segment, client), keys));
205
+ return bundle?.v === SEGMENT_SCHEMA_VERSION && Array.isArray(bundle.documents) ? bundle.documents.filter(accept) : undefined;
206
+ }
207
+ const readSegment = (mailboxUid, segment, keys, client) => readSegmentAs(mailboxUid, segment, keys, isDocument, client);
208
+ /** `documents` gzipped and sealed to `key` as a segment file, base64. */
209
+ async function sealSegment(documents, key) {
210
+ const file = await gzip(new TextEncoder().encode(JSON.stringify({ v: SEGMENT_SCHEMA_VERSION, documents })));
211
+ return toBase64(await encryptForRecipients(file, [key.certDer]));
212
+ }
213
+ function isClientDocument(value) {
214
+ const doc = value;
215
+ return !!doc && doc.v === 1 && typeof doc.messageUid === "string" && typeof doc.dateForSort === "string" && typeof doc.participants === "string";
216
+ }
217
+ /** A local index row as a client segment document. */
218
+ export function clientDocument(entity) {
219
+ return {
220
+ v: 1,
221
+ messageUid: entity.entityUid,
222
+ folderUid: entity.folderUid,
223
+ dateForSort: entity.dateForSort,
224
+ participants: entity.participants,
225
+ flags: entity.flags,
226
+ hasAttachments: entity.hasAttachments,
227
+ subject: entity.subject,
228
+ body: entity.body,
229
+ attachmentText: entity.attachmentText,
230
+ entityVersion: entity.entityVersion,
231
+ };
232
+ }
233
+ /** A client segment document as a row of the local index. */
234
+ export function clientDocumentEntity(mailboxUid, doc) {
235
+ const text = [doc.subject, doc.body, doc.attachmentText, doc.participants].reduce((total, value) => total + (value?.length ?? 0), 0);
236
+ return {
237
+ entityType: "message",
238
+ entityUid: doc.messageUid,
239
+ mailboxUid,
240
+ ...(doc.folderUid !== undefined ? { folderUid: doc.folderUid } : {}),
241
+ dateForSort: doc.dateForSort,
242
+ participants: doc.participants,
243
+ flags: typeof doc.flags === "string" ? doc.flags : "",
244
+ hasAttachments: doc.hasAttachments === true,
245
+ ...(doc.subject !== undefined ? { subject: doc.subject } : {}),
246
+ ...(doc.body !== undefined ? { body: doc.body } : {}),
247
+ ...(doc.attachmentText !== undefined ? { attachmentText: doc.attachmentText } : {}),
248
+ byteSize: text,
249
+ ...(doc.entityVersion !== undefined ? { entityVersion: doc.entityVersion } : {}),
250
+ };
251
+ }
252
+ /** The ISO 8601 week (UTC) of `date`, as `YYYY-Www` - the week a message's segment is filed under (restapi's `isoWeekId()`). */
253
+ export function isoWeekId(date) {
254
+ const day = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
255
+ // The Thursday of this ISO week decides its year (weeks start on Monday; getUTCDay() has Sunday as 0).
256
+ const weekday = day.getUTCDay() || 7;
257
+ day.setUTCDate(day.getUTCDate() + 4 - weekday);
258
+ const yearStart = new Date(Date.UTC(day.getUTCFullYear(), 0, 1));
259
+ const week = Math.ceil(((day.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
260
+ return `${day.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
261
+ }
262
+ /**
263
+ * The sync state, started over when the local index is empty (nothing remembered in it is there any more). What was stored is dropped then
264
+ * too: it describes an index that is gone (pruned, or discarded as corrupted, behind this module's back), and a later pass that found this
265
+ * index filled by the build's end-to-end mail would take it up and skip the segments it never got.
266
+ */
267
+ async function currentState(mailboxUid) {
268
+ if ((await getLocalCoverage(mailboxUid)).indexedCount > 0) {
269
+ return loadState(mailboxUid);
270
+ }
271
+ try {
272
+ localStorage.removeItem(zeroAccessSyncStateKey(mailboxUid));
273
+ }
274
+ catch {
275
+ // Nothing stored to drop.
276
+ }
277
+ return { segments: {} };
278
+ }
279
+ /**
280
+ * Imports the client segments of `mailboxUid` (end-to-end mail indexed by another device - see this module's comment) not yet imported in
281
+ * their current generation, newest week first: how many. `manifest` saves reading it again; `generation` is the calling build pass's (see
282
+ * `GenerationParams`). Segments this device can't open are skipped.
283
+ */
284
+ export async function importClientSegments(mailboxUid, unlocked, client, { manifest, generation } = {}) {
285
+ const keys = unlocked.zeroAccessKeys;
286
+ if (!keys?.length) {
287
+ return 0;
288
+ }
289
+ const epoch = zeroAccessSyncEpoch(mailboxUid);
290
+ // Read first, while a new index is still empty: its stale state is dropped even when the manifest can't be read.
291
+ const state = await currentState(mailboxUid);
292
+ const segments = (manifest ?? (await getSearchIndexManifest(mailboxUid, client))).segments.filter((segment) => segment.origin === "client");
293
+ let imported = 0;
294
+ for (const segment of segments.sort((a, b) => b.weekId.localeCompare(a.weekId))) {
295
+ const key = `${segment.weekId}:${segment.origin}`;
296
+ if (state.segments[key] === segment.etag) {
297
+ continue;
298
+ }
299
+ const docs = await readSegmentAs(mailboxUid, segment, keys, isClientDocument, client);
300
+ if (!docs) {
301
+ continue;
302
+ }
303
+ for (let i = 0; i < docs.length; i += IMPORT_BATCH) {
304
+ await indexLocalEntities(mailboxUid, docs.slice(i, i + IMPORT_BATCH).map((doc) => clientDocumentEntity(mailboxUid, doc)), generation);
305
+ }
306
+ state.segments[key] = segment.etag;
307
+ saveState(mailboxUid, state, epoch);
308
+ imported++;
309
+ }
310
+ return imported;
311
+ }
312
+ /**
313
+ * Uploads end-to-end messages this device just decrypted and indexed (`entities`) to `mailboxUid`'s client segments, one per ISO week of
314
+ * `dateForSort`, each merged by message into the week's current segment and sealed to the active zero-access key: how many weeks were
315
+ * written. A week whose current segment this device can't read is left alone (merging would drop what is in it), as is one that already
316
+ * holds every document at the same version. Another device replacing a week first (`409`) is retried with its generation, up to
317
+ * `CLIENT_UPLOAD_ATTEMPTS` times. Does nothing without unlocked zero-access keys.
318
+ */
319
+ export async function uploadClientDocuments(mailboxUid, unlocked, entities, client) {
320
+ const keys = unlocked.zeroAccessKeys;
321
+ if (!keys?.length || entities.length === 0) {
322
+ return 0;
323
+ }
324
+ const weeks = new Map();
325
+ for (const entity of entities) {
326
+ const weekId = isoWeekId(new Date(entity.dateForSort));
327
+ let week = weeks.get(weekId);
328
+ if (!week) {
329
+ week = [];
330
+ weeks.set(weekId, week);
331
+ }
332
+ week.push(clientDocument(entity));
333
+ }
334
+ const epoch = zeroAccessSyncEpoch(mailboxUid);
335
+ const state = loadState(mailboxUid);
336
+ let written = 0;
337
+ let manifest;
338
+ for (const [weekId, docs] of weeks) {
339
+ for (let attempt = 0; attempt < CLIENT_UPLOAD_ATTEMPTS; attempt++) {
340
+ manifest ?? (manifest = await getSearchIndexManifest(mailboxUid, client));
341
+ const current = manifest.segments.find((segment) => segment.origin === "client" && segment.weekId === weekId);
342
+ const previous = current ? await readSegmentAs(mailboxUid, current, keys, isClientDocument, client) : [];
343
+ if (!previous) {
344
+ break;
345
+ }
346
+ const merged = new Map(previous.map((doc) => [doc.messageUid, doc]));
347
+ if (docs.every((doc) => merged.has(doc.messageUid) && merged.get(doc.messageUid).entityVersion === doc.entityVersion)) {
348
+ break;
349
+ }
350
+ for (const doc of docs) {
351
+ merged.set(doc.messageUid, doc);
352
+ }
353
+ try {
354
+ const stored = await putSearchIndexSegment(mailboxUid, weekId, {
355
+ expectedGeneration: current?.generation ?? 0,
356
+ segment: await sealSegment([...merged.values()], keys[0]),
357
+ keyFingerprint: keys[0].fingerprint,
358
+ schemaVersion: SEGMENT_SCHEMA_VERSION,
359
+ origin: "client",
360
+ }, client);
361
+ // This device already holds every document of the new generation only if it held the previous one.
362
+ if (current && state.segments[`${weekId}:client`] === current.etag) {
363
+ state.segments[`${weekId}:client`] = stored.etag;
364
+ saveState(mailboxUid, state, epoch);
365
+ }
366
+ written++;
367
+ break;
368
+ }
369
+ catch (err) {
370
+ if (!(err instanceof ApiRequestError && err.status === 409)) {
371
+ throw err;
372
+ }
373
+ manifest = undefined;
374
+ }
375
+ }
376
+ }
377
+ return written;
378
+ }
379
+ /** Every pending entry of `week`, paged through. */
380
+ async function listAllEntries(mailboxUid, filter, client) {
381
+ const entries = [];
382
+ let after;
383
+ do {
384
+ const page = await listSearchIndexEntries(mailboxUid, { ...filter, ...(after ? { after } : {}) }, client);
385
+ entries.push(...page.entries);
386
+ after = page.next;
387
+ } while (after);
388
+ return entries;
389
+ }
390
+ /** Per mailbox, the weeks a compaction found it can't fold on this device - a segment it can't read, or pending entries sealed to a key
391
+ * it doesn't hold (an older key a shared mailbox's member hasn't been given yet) - with how many entries were pending then, and the
392
+ * zero-access keys it had. Such a week stays pending, and the oldest weeks come first: without this, a few of them would take every pass's
393
+ * `MAX_COMPACTIONS_PER_PASS` and no other week would ever be compacted here. A week is tried again once its count changes (new entries,
394
+ * or another device folded it), and every week once this device's keys do. */
395
+ const stalled = new Map();
396
+ /** The weeks of `mailboxUid` this device found it can't fold with `keys` (see `stalled`). */
397
+ function stalledWeeks(mailboxUid, keys) {
398
+ const fingerprints = keys.map((key) => key.fingerprint).join(",");
399
+ let entry = stalled.get(mailboxUid);
400
+ if (entry?.keys !== fingerprints) {
401
+ entry = { keys: fingerprints, weeks: new Map() };
402
+ stalled.set(mailboxUid, entry);
403
+ }
404
+ return entry.weeks;
405
+ }
406
+ /** The documents of `entries` that open, with the uids of those entries; how many didn't. */
407
+ async function openEntries(entries, keys) {
408
+ const docs = [];
409
+ const uids = [];
410
+ let unreadable = 0;
411
+ for (const entry of entries) {
412
+ const doc = await openSealedJson(fromBase64(entry.sealed), keys);
413
+ if (isDocument(doc)) {
414
+ docs.push(doc);
415
+ uids.push(entry.uid);
416
+ }
417
+ else {
418
+ unreadable++;
419
+ }
420
+ }
421
+ return { docs, uids, unreadable };
422
+ }
423
+ /**
424
+ * One sync pass for `mailboxUid` (see this module's comment). Does nothing without unlocked zero-access keys. Rejects when the manifest
425
+ * can't be read; any later failure ends the pass early with what it had done so far remembered.
426
+ */
427
+ export async function syncZeroAccessIndex(mailboxUid, unlocked, client) {
428
+ const result = { segmentsImported: 0, clientSegmentsImported: 0, entriesImported: 0, weeksCompacted: 0, unreadable: 0 };
429
+ const keys = unlocked.zeroAccessKeys;
430
+ if (!keys?.length) {
431
+ return result;
432
+ }
433
+ const epoch = zeroAccessSyncEpoch(mailboxUid);
434
+ const manifest = await getSearchIndexManifest(mailboxUid, client);
435
+ let state = await currentState(mailboxUid);
436
+ // Segments, newest week first.
437
+ const zeroAccessSegments = manifest.segments.filter((segment) => segment.origin === "zero-access");
438
+ for (const segment of [...zeroAccessSegments].sort((a, b) => b.weekId.localeCompare(a.weekId))) {
439
+ const key = `${segment.weekId}:${segment.origin}`;
440
+ if (state.segments[key] === segment.etag) {
441
+ continue;
442
+ }
443
+ const docs = await readSegment(mailboxUid, segment, keys, client);
444
+ if (!docs) {
445
+ result.unreadable++;
446
+ continue;
447
+ }
448
+ await importDocuments(mailboxUid, docs);
449
+ state.segments[key] = segment.etag;
450
+ result.segmentsImported++;
451
+ saveState(mailboxUid, state, epoch);
452
+ }
453
+ // End-to-end mail other devices indexed.
454
+ saveState(mailboxUid, state, epoch);
455
+ result.clientSegmentsImported = await importClientSegments(mailboxUid, unlocked, client, { manifest });
456
+ state = loadState(mailboxUid);
457
+ // The live tail, a page at a time: without a `since` (a new or emptied local index) it is every pending entry of the mailbox, which
458
+ // after zero-access is turned on can be every message it has - never held, opened, all at once. Each page imported is remembered
459
+ // (`tailCursor`), so a pass that ends early is taken up where it stopped rather than from the start.
460
+ const since = state.tailSince ? new Date(new Date(state.tailSince).getTime() - TAIL_OVERLAP_MS).toISOString() : undefined;
461
+ const resumed = state.tailCursor?.since === since ? state.tailCursor : undefined;
462
+ const tailStart = resumed?.start ?? new Date().toISOString();
463
+ // Rows nothing has recorded a folder and flags for: not on this device with a version yet (the builder's mark), nor remembered by it.
464
+ // Asked in batches - a backlog's tail can be every message of the mailbox, past what one SQL statement may bind - and only until there
465
+ // are as many as a pass looks up.
466
+ const remembered = rememberedMetadata.get(mailboxUid);
467
+ const asked = new Set();
468
+ const unrecorded = [];
469
+ let after = resumed?.after;
470
+ do {
471
+ const page = await listSearchIndexEntries(mailboxUid, { ...(since ? { since } : {}), ...(after ? { after } : {}) }, client);
472
+ const tail = await openEntries(page.entries, keys);
473
+ const tailUids = [...new Set(tail.docs.map((doc) => doc.messageUid))].filter((uid) => !asked.has(uid));
474
+ tailUids.forEach((uid) => asked.add(uid));
475
+ for (let i = 0; i < tailUids.length && unrecorded.length < MAX_TAIL_LOOKUPS; i += IMPORT_BATCH) {
476
+ const batch = tailUids.slice(i, i + IMPORT_BATCH);
477
+ const recorded = await getIndexedVersions(mailboxUid, batch);
478
+ unrecorded.push(...batch.filter((uid) => recorded[uid] === undefined && !remembered?.has(uid)));
479
+ }
480
+ await importDocuments(mailboxUid, tail.docs);
481
+ result.entriesImported += tail.docs.length;
482
+ result.unreadable += tail.unreadable;
483
+ after = page.next;
484
+ if (after) {
485
+ state.tailCursor = { ...(since ? { since } : {}), after, start: tailStart };
486
+ saveState(mailboxUid, state, epoch);
487
+ }
488
+ } while (after);
489
+ await recordTailMetadata(mailboxUid, unrecorded, client);
490
+ state.tailSince = tailStart;
491
+ delete state.tailCursor;
492
+ saveState(mailboxUid, state, epoch);
493
+ // Compaction of closed weeks, passing over those this device found it can't fold (`stalled`) while nothing has changed in them.
494
+ const active = keys[0];
495
+ const stalledHere = stalledWeeks(mailboxUid, keys);
496
+ const segmentOf = (weekId) => zeroAccessSegments.find((segment) => segment.weekId === weekId);
497
+ const weeks = manifest.weeks.filter((w) => stalledHere.get(w.weekId) !== w.pendingEntries && shouldCompact(w, segmentOf(w.weekId)?.bytes ?? 0));
498
+ for (const week of weeks.slice(0, MAX_COMPACTIONS_PER_PASS)) {
499
+ const current = segmentOf(week.weekId);
500
+ const previous = current ? await readSegment(mailboxUid, current, keys, client) : [];
501
+ if (!previous) {
502
+ // A generation this device can't read can't be folded into: leave the week to a device that can.
503
+ stalledHere.set(week.weekId, week.pendingEntries);
504
+ continue;
505
+ }
506
+ const pending = await openEntries(await listAllEntries(mailboxUid, { week: week.weekId }, client), keys);
507
+ if (pending.uids.length === 0) {
508
+ stalledHere.set(week.weekId, week.pendingEntries);
509
+ continue;
510
+ }
511
+ // What this device can't open stays pending once the rest is folded: the week is passed over until that count changes.
512
+ if (pending.unreadable > 0) {
513
+ stalledHere.set(week.weekId, pending.unreadable);
514
+ }
515
+ else {
516
+ stalledHere.delete(week.weekId);
517
+ }
518
+ const merged = new Map(previous.map((doc) => [doc.messageUid, doc]));
519
+ for (const doc of pending.docs) {
520
+ merged.set(doc.messageUid, doc);
521
+ }
522
+ try {
523
+ const stored = await putSearchIndexSegment(mailboxUid, week.weekId, {
524
+ expectedGeneration: current?.generation ?? 0,
525
+ segment: await sealSegment([...merged.values()], active),
526
+ keyFingerprint: active.fingerprint,
527
+ schemaVersion: SEGMENT_SCHEMA_VERSION,
528
+ foldedEntryUids: pending.uids,
529
+ }, client);
530
+ // This device already holds every document of the new generation.
531
+ await importDocuments(mailboxUid, pending.docs);
532
+ state.segments[`${stored.weekId}:${stored.origin}`] = stored.etag;
533
+ saveState(mailboxUid, state, epoch);
534
+ result.weeksCompacted++;
535
+ }
536
+ catch (err) {
537
+ // Another device compacted the week first: the next pass imports its generation.
538
+ if (!(err instanceof ApiRequestError && err.status === 409)) {
539
+ throw err;
540
+ }
541
+ }
542
+ }
543
+ return result;
544
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Where a zero-access sync pass (`zeroAccessIndexSync.ts`) remembers what it imported into a mailbox's local index - the segments it
3
+ * holds, the live tail's last read - and the forgetting of it when that index is destroyed (`localIndexRpcClient.ts`'s
4
+ * `destroyLocalIndex()`/`destroyAllLocalIndexes()`: a lock, sign-out; `pruneInaccessibleLocalIndexes()`; the Worker discarding a corrupted
5
+ * index). Apart from both so neither imports the other.
6
+ *
7
+ * What was remembered describes an index that is gone, so it must not outlive it - a new index the build fills first (decrypted
8
+ * end-to-end mail) would otherwise be taken for the old one, and the segments it never got skipped. A pass that started before the destroy
9
+ * must not write it back either: each pass reads `zeroAccessSyncEpoch()` when it starts and saves nothing once it changed.
10
+ */
11
+ /** The localStorage key of `mailboxUid`'s sync state. */
12
+ export declare const zeroAccessSyncStateKey: (mailboxUid: string) => string;
13
+ /** Changes whenever `mailboxUid`'s sync state is forgotten. */
14
+ export declare function zeroAccessSyncEpoch(mailboxUid: string): string;
15
+ /** Forgets what a pass remembered for `mailboxUid` (its local index is destroyed). */
16
+ export declare function forgetZeroAccessSync(mailboxUid: string): void;
17
+ /** Forgets what passes remembered for every mailbox (every local index is destroyed). */
18
+ export declare function forgetAllZeroAccessSync(): void;
19
+ /** Forgets what passes remembered for every mailbox not in `keep` (their local indexes are pruned). */
20
+ export declare function forgetZeroAccessSyncExcept(keep: ReadonlySet<string>): void;
@@ -0,0 +1,65 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Where a zero-access sync pass (`zeroAccessIndexSync.ts`) remembers what it imported into a mailbox's local index - the segments it
7
+ * holds, the live tail's last read - and the forgetting of it when that index is destroyed (`localIndexRpcClient.ts`'s
8
+ * `destroyLocalIndex()`/`destroyAllLocalIndexes()`: a lock, sign-out; `pruneInaccessibleLocalIndexes()`; the Worker discarding a corrupted
9
+ * index). Apart from both so neither imports the other.
10
+ *
11
+ * What was remembered describes an index that is gone, so it must not outlive it - a new index the build fills first (decrypted
12
+ * end-to-end mail) would otherwise be taken for the old one, and the segments it never got skipped. A pass that started before the destroy
13
+ * must not write it back either: each pass reads `zeroAccessSyncEpoch()` when it starts and saves nothing once it changed.
14
+ */
15
+ const PREFIX = "rapidmx:za-index-sync:";
16
+ /** The localStorage key of `mailboxUid`'s sync state. */
17
+ export const zeroAccessSyncStateKey = (mailboxUid) => `${PREFIX}${mailboxUid}`;
18
+ const epochs = new Map();
19
+ let allEpoch = 0;
20
+ /** Changes whenever `mailboxUid`'s sync state is forgotten. */
21
+ export function zeroAccessSyncEpoch(mailboxUid) {
22
+ return `${allEpoch}:${epochs.get(mailboxUid) ?? 0}`;
23
+ }
24
+ /** Forgets what a pass remembered for `mailboxUid` (its local index is destroyed). */
25
+ export function forgetZeroAccessSync(mailboxUid) {
26
+ epochs.set(mailboxUid, (epochs.get(mailboxUid) ?? 0) + 1);
27
+ try {
28
+ localStorage.removeItem(zeroAccessSyncStateKey(mailboxUid));
29
+ }
30
+ catch {
31
+ // Nothing to forget.
32
+ }
33
+ }
34
+ /** Forgets what passes remembered for every mailbox (every local index is destroyed). */
35
+ export function forgetAllZeroAccessSync() {
36
+ allEpoch++;
37
+ try {
38
+ storedMailboxUids().forEach((mailboxUid) => localStorage.removeItem(zeroAccessSyncStateKey(mailboxUid)));
39
+ }
40
+ catch {
41
+ // Nothing to forget.
42
+ }
43
+ }
44
+ /** Forgets what passes remembered for every mailbox not in `keep` (their local indexes are pruned). */
45
+ export function forgetZeroAccessSyncExcept(keep) {
46
+ let stored;
47
+ try {
48
+ stored = storedMailboxUids();
49
+ }
50
+ catch {
51
+ return;
52
+ }
53
+ stored.filter((mailboxUid) => !keep.has(mailboxUid)).forEach(forgetZeroAccessSync);
54
+ }
55
+ /** The mailboxes some sync state is stored for. Throws when storage can't be read. */
56
+ function storedMailboxUids() {
57
+ const uids = [];
58
+ for (let i = 0; i < localStorage.length; i++) {
59
+ const key = localStorage.key(i);
60
+ if (key?.startsWith(PREFIX)) {
61
+ uids.push(key.slice(PREFIX.length));
62
+ }
63
+ }
64
+ return uids;
65
+ }
@@ -0,0 +1,7 @@
1
+ import React from "react";
2
+ import { type SealedRecord } from "../../../lib/zeroAccess/sealedRecords.js";
3
+ /** A small lock beside a calendar event, contact or task whose details zero-access encryption protects (`sealedFields`) - nothing for
4
+ * one it doesn't. Never says "end-to-end": the server sees what is saved while it saves it (restapi `specs/search.md` §3). */
5
+ export default function ProtectedMark({ record }: {
6
+ record: SealedRecord;
7
+ }): React.JSX.Element | null;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { isProtectedRecord } from "../../../lib/zeroAccess/sealedRecords.js";
3
+ /** A small lock beside a calendar event, contact or task whose details zero-access encryption protects (`sealedFields`) - nothing for
4
+ * one it doesn't. Never says "end-to-end": the server sees what is saved while it saves it (restapi `specs/search.md` §3). */
5
+ export default function ProtectedMark({ record }) {
6
+ if (!isProtectedRecord(record)) {
7
+ return null;
8
+ }
9
+ return (_jsx("span", { className: "ml-1 text-text-muted text-xs", role: "img", "aria-label": "Protected by zero-access encryption", title: "Protected by zero-access encryption", children: "\uD83D\uDD12" }));
10
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Calls `onLocked` with the mailbox whenever a mailbox's keys lock (the idle timeout, a lock from settings, signing out) - for a page that
3
+ * holds records it opened with them in its state: the opened-value caches are cleared on a lock, but a page's state is not, so it reloads
4
+ * (the reloaded records keep their placeholders) and closes what shows opened content. The latest `onLocked` is called; subscribed once.
5
+ */
6
+ export declare function useKeysLocked(onLocked: (mailboxUid: string) => void): void;