@rapidmx/web-client 0.36.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 (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { cancelScheduledSend, getMessage, listAttachments, listFolders, queueMessageSend } from "../../../../lib/mail/mailApi.js";
5
+ import { cancelScheduledSend, deleteMessage, getMessage, listAttachments, listFolders, queueMessageSend } from "../../../../lib/mail/mailApi.js";
6
6
  import { describeSendEventError } from "../../../../lib/mail/sendEvents.js";
7
7
  import { formatRecipient } from "../../components/mail/compose/recipients.js";
8
8
  import { loadOriginalMessage } from "../../components/mail/compose/quotedBody.js";
@@ -11,7 +11,7 @@ import { dismiss, notify } from "../../notifications/store.js";
11
11
  import { beginPendingSend, finishPendingSend, setPendingStage } from "./pendingSends.js";
12
12
  import { failOutgoing, forgetOutgoing, markOutgoingSending, markOutgoingSent } from "./outgoingReplies.js";
13
13
  import { openComposeFromOutside } from "./composeBridge.js";
14
- import { describeMessage, forgetRetainedRequest, notifySent, openDraftFromRequest, retainedRequest } from "./sendJob.js";
14
+ import { copyFailed, copyRetried, describeMessage, forgetCopies, forgetRetainedRequest, notifySent, openDraftFromRequest, retainedRequest, unsentCopies, } from "./sendJob.js";
15
15
  /**
16
16
  * What the user is told when the server reports how a queued message went (`send-succeeded`, `send-failed`, `send-retrying` push events,
17
17
  * see `@rapidmx/react-shared`'s `parseSendEvent()`): a quiet, single "Message sent" for a message this tab sent (several close together are
@@ -36,14 +36,39 @@ async function moveBackToDrafts(uid, mailboxUid, client) {
36
36
  function escapeHtml(text) {
37
37
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
38
38
  }
39
- /** "Open draft" for a message the server failed to relay: back into Drafts, and into a compose window with what was written. */
40
- async function openFailedDraft(event, client) {
39
+ /** "Open draft" for a message the server failed to relay: back into Drafts, and into a compose window with what was written. `request`: the
40
+ * one it was queued from, when this tab holds it. */
41
+ async function openFailedDraft(event, client, request) {
42
+ // A message sent as several copies (one per encryption format) is written again only when none of them went: while another copy has
43
+ // gone or may still go, the whole message sent again would reach its recipients a second time - however the action came to be offered.
44
+ const copies = unsentCopies(request);
45
+ if (request?.copyFates && !copies) {
46
+ notify({
47
+ id: `open-draft:${event.uid}`,
48
+ kind: "info",
49
+ title: "Only this copy didn't go",
50
+ message: "The copy for its other recipients is sent on its own. Use Retry to send this one to its own recipients.",
51
+ timeoutMs: 8000,
52
+ });
53
+ return;
54
+ }
41
55
  try {
42
- const moved = await moveBackToDrafts(event.uid, event.mailboxUid ?? "", client);
56
+ // Of a message sent as several copies, its own draft is the one written again.
57
+ const moved = await moveBackToDrafts(copies ? request.draft.uid : event.uid, event.mailboxUid ?? "", client);
43
58
  // Back in Drafts it is no longer a message on its way in its conversation.
44
59
  forgetOutgoing(event.uid);
45
- const request = retainedRequest(event.uid);
46
60
  if (request) {
61
+ // The other copies (the OpenPGP one's own draft) go: the message is encrypted afresh when it is sent again, and none of them may
62
+ // be retried meanwhile.
63
+ for (const uid of copies?.filter((uid) => uid !== request.draft.uid) ?? []) {
64
+ const extra = await moveBackToDrafts(uid, event.mailboxUid ?? "", client);
65
+ await deleteMessage(extra.uid, extra.version, client);
66
+ dismiss(failedId(uid));
67
+ forgetOutgoing(uid);
68
+ }
69
+ if (request.copyFates) {
70
+ forgetCopies(request.copyFates);
71
+ }
47
72
  openDraftFromRequest({ ...request, draft: moved });
48
73
  return;
49
74
  }
@@ -81,21 +106,25 @@ async function openFailedDraft(event, client) {
81
106
  notifyApiError(err, "Couldn't open this draft");
82
107
  }
83
108
  }
84
- /** "Retry" for a message the server failed to relay: the same send call again - a failed message stays in Outbox and the server queues it afresh (with a new retry budget). */
85
- async function retryFailed(event, client) {
109
+ /** "Retry" for a message the server failed to relay: the same send call again - a failed message stays in Outbox and the server queues it afresh (with a new retry budget).
110
+ * `request`: the one it was queued from, when this tab holds it - a copy of a message sent as several is then awaited again. */
111
+ async function retryFailed(event, client, request) {
86
112
  if (!beginPendingSend({ draftUid: event.uid, mailboxUid: event.mailboxUid ?? "", subject: event.subject, recipients: event.recipients, scheduled: false })) {
87
113
  return;
88
114
  }
89
115
  dismiss(failedId(event.uid));
90
116
  markOutgoingSending(event.uid);
117
+ copyRetried(request, event.uid);
91
118
  try {
92
119
  setPendingStage(event.uid, "queuing");
93
120
  const result = await queueMessageSend(event.uid, client);
94
- if (!result.queued) {
121
+ // Relayed before the server answered - and so, when it is one copy of several, maybe the last of them.
122
+ if (!result.queued && forgetRetainedRequest(event.uid)) {
95
123
  notifySent();
96
124
  }
97
125
  }
98
126
  catch (err) {
127
+ copyFailed(request, event.uid);
99
128
  notifyApiError(err, "This message wasn't sent", { dedupeKey: failedId(event.uid) });
100
129
  }
101
130
  finally {
@@ -108,26 +137,29 @@ async function retryFailed(event, client) {
108
137
  export function handleSendEvent(event, client) {
109
138
  if (event.action === "send-succeeded") {
110
139
  dismiss(retryId(event.uid));
111
- dismiss(failedId(event.uid));
140
+ // A partly-sent message's failure (`notifyFailure()`) is shown under its first copy's uid but is of a copy that was never handed over:
141
+ // it stays, with the Retry that hands that copy over.
142
+ if (![...(retainedRequest(event.uid)?.copyFates?.values() ?? [])].includes("unsent")) {
143
+ dismiss(failedId(event.uid));
144
+ }
112
145
  // The conversation on screen swaps the message it drew for the Sent Items copy (a message this tab did not send has nothing drawn).
113
146
  markOutgoingSent(event.uid);
114
147
  // The server reports every message it relays, scheduled ones from long ago included: only a message this tab queued in the background is
115
148
  // worth a confirmation (several are one pop-up counting up). Anything else - a send-later message going out, another tab's send - is quiet.
116
- if (retainedRequest(event.uid)) {
117
- forgetRetainedRequest(event.uid);
149
+ // A message sent as several copies (one per encryption format) is confirmed once, when the last of them has gone.
150
+ if (retainedRequest(event.uid) && forgetRetainedRequest(event.uid)) {
118
151
  notifySent();
119
152
  }
120
153
  return;
121
154
  }
122
155
  const failure = describeSendEventError(event.error, event.action === "send-failed" ? "The server couldn't send this message." : "The server couldn't send this message yet.");
123
- const what = describeMessage(event.subject, summary(event.recipients));
124
156
  if (event.action === "send-retrying") {
125
157
  const next = event.nextAttemptAt ? Date.parse(event.nextAttemptAt) : NaN;
126
158
  notify({
127
159
  id: retryId(event.uid),
128
160
  kind: "info",
129
161
  title: "Retrying to send",
130
- message: `${what} (attempt ${event.attempt + 1}${Number.isNaN(next) ? "" : `, at ${new Date(next).toLocaleTimeString()}`}). ${failure.message}`,
162
+ message: `${describeMessage(event.subject, summary(event.recipients))} (attempt ${event.attempt + 1}${Number.isNaN(next) ? "" : `, at ${new Date(next).toLocaleTimeString()}`}). ${failure.message}`,
131
163
  details: failure.lines,
132
164
  sticky: false,
133
165
  timeoutMs: 8000,
@@ -135,18 +167,42 @@ export function handleSendEvent(event, client) {
135
167
  return;
136
168
  }
137
169
  dismiss(retryId(event.uid));
138
- const actions = [
139
- { label: "Retry", onClick: () => void retryFailed(event, client) },
140
- { label: "Open draft", onClick: () => void openFailedDraft(event, client) },
141
- ];
170
+ const request = retainedRequest(event.uid);
171
+ copyFailed(request, event.uid);
172
+ notifySendFailure(event, failure, request, client);
173
+ }
174
+ /**
175
+ * The sticky "This message wasn't sent" for a message that failed, `failure` saying why, with what can be done about it - for one the server
176
+ * failed to relay, and for a message sent as several copies (`SendRequest.copyUids`) of which this tab couldn't hand over the rest after the
177
+ * server had failed one (`sendJob.ts`'s `notifyFailure()`). `request`: the one it was queued from, when this tab holds it.
178
+ */
179
+ export function notifySendFailure(event, failure, request, client) {
180
+ // Every copy of a message sent as several (one per encryption format, `SendRequest.copyUids`) failed or never went: nobody has it, and it
181
+ // is the whole message's failure - one pop-up, whose Retry hands each copy over again and whose "Open draft" writes the message again.
182
+ const copies = unsentCopies(request);
183
+ // One copy of several, while another has gone or may still go: the others' recipients have theirs or will, and "Open draft" would send
184
+ // the whole message to them again - Retry sends just this copy, to just its own recipients.
185
+ const oneCopy = !!request?.copyFates && !copies;
186
+ const retry = () => {
187
+ for (const uid of copies ?? [event.uid]) {
188
+ void retryFailed({ ...event, uid }, client, request);
189
+ }
190
+ };
191
+ const actions = [{ label: "Retry", onClick: retry }, ...(oneCopy ? [] : [{ label: "Open draft", onClick: () => void openFailedDraft(event, client, request) }])];
192
+ const message = oneCopy ? `The copy for some of its recipients didn't go - the others' copy is sent on its own. ${failure.message}` : failure.message;
193
+ // What an earlier copy's failure said of the others is no longer so: this pop-up stands for the whole message.
194
+ for (const uid of copies?.filter((uid) => uid !== event.uid) ?? []) {
195
+ dismiss(failedId(uid));
196
+ failOutgoing(uid, message, actions);
197
+ }
142
198
  notify({
143
199
  id: failedId(event.uid),
144
200
  kind: "error",
145
201
  title: "This message wasn't sent",
146
- message: `${what}: ${failure.message}`,
202
+ message: `${describeMessage(event.subject, summary(event.recipients))}: ${message}`,
147
203
  details: failure.lines,
148
204
  actions,
149
205
  });
150
206
  // The conversation on screen shows the same failure under the message, with the same ways out.
151
- failOutgoing(event.uid, failure.message, actions);
207
+ failOutgoing(event.uid, message, actions);
152
208
  }
@@ -8,20 +8,23 @@
8
8
  * "MUST be destroyed on the same events that destroy private keys: explicit logout, session revocation,
9
9
  * and the configurable idle timeout").
10
10
  *
11
- * **Why polling, not hooking the existing destroy call sites directly**: `destroyUnlockedKeys()`
12
- * (`react-shared/src/crypto/keySession.ts`) has no observer/callback hook. Watching `getUnlockedKeys()`
13
- * for a present-to-absent transition catches *every* destroy path uniformly - idle timeout, the manual
14
- * button, or anything else that calls it - without needing to know which one fired.
11
+ * **Locks**: `localIndexRpcClient.ts` itself destroys an index it opened the moment `subscribeKeySession()` reports its mailbox locked,
12
+ * whatever destroyed the keys (idle timeout, the manual button, a sign-out) and whether or not this component is still mounted. This
13
+ * component subscribes to the same `locked` event to reset what it remembers of the mailbox (built, synced) at that moment, so a re-unlock
14
+ * builds again - even one made before the next poll tick, which a poll alone would never see as a lock, leaving the destroyed index
15
+ * unbuilt and every zero-access sync failing against it. Keys only ever go away through `destroyUnlockedKeys()`, which always reports
16
+ * the lock, so the poll doesn't watch for a lock itself. A lock while a build is still deriving its index key, before anything is open
17
+ * for the index client to destroy, is the builder's own to catch (`localIndexBuilder.ts`): it opens nothing with destroyed keys.
15
18
  *
16
19
  * Every mailbox this component has seen unlocked is tracked for the life of the mount - not just whichever
17
20
  * is active right now - so switching mailboxes (or a folders-list refresh re-running the build effect)
18
21
  * can never lose a pending unlocked-to-locked transition for a mailbox whose index was already built.
19
22
  *
20
- * **Explicit logout** navigates away immediately, which may not leave time for a poll tick - `AppShell.tsx`
23
+ * **Explicit logout** navigates away immediately, which may not leave time for the lock's asynchronous destroy - `AppShell.tsx`
21
24
  * handles that case itself via `destroyAllLocalIndexes()`.
22
25
  */
23
26
  import { useEffect, useRef } from "react";
24
- import { getUnlockedKeys } from "../../../lib/crypto/keySession.js";
27
+ import { getUnlockedKeys, subscribeKeySession } from "../../../lib/crypto/keySession.js";
25
28
  import { useApiClient } from "../../../lib/util/apiClientContext.js";
26
29
  /**
27
30
  * The index builder (with the message security code it decrypts through - PKI.js and the ASN.1/X.509 libraries, over half a
@@ -40,7 +43,7 @@ const loadSyncer = () => (syncer ?? (syncer = import("./zeroAccessIndexSync.js")
40
43
  syncer = undefined;
41
44
  throw err;
42
45
  })));
43
- import { destroyLocalIndex, pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
46
+ import { pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
44
47
  export const POLL_INTERVAL_MS = 5000;
45
48
  /** How often an unlocked zero-access mailbox's local index is brought up to date with the server's sealed index. */
46
49
  export const ZERO_ACCESS_SYNC_INTERVAL_MS = 120000;
@@ -50,29 +53,40 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
50
53
  // apps, Electron), which keeps building the index against the default global cookie-based session exactly as before. A native,
51
54
  // multi-account host that renders this under an ApiClientContext.Provider gets its own account's client threaded through instead.
52
55
  const client = useApiClient();
53
- // mailboxUid -> whether it was unlocked at the last check. An entry exists for every mailbox whose
54
- // build this component started; only ever changed by a real observed transition, never reset by an
55
- // effect re-run.
56
+ // mailboxUid -> whether it is unlocked as far as this component knows: set when its build starts, cleared by its lock. An entry exists
57
+ // for every mailbox whose build this component started; never reset by an effect re-run.
56
58
  const trackedRef = useRef(new Map());
57
59
  // The latest props (and client), for the long-lived polling interval below.
58
60
  const latestRef = useRef({ mailboxUid, folders, client });
59
61
  latestRef.current = { mailboxUid, folders, client };
60
- // mailboxUid -> when its last zero-access sync started. Set only once its first build finished (the sync writes into the index the
61
- // build opened); a mailbox in `syncingRef` has a pass running, and never gets a second one alongside it.
62
+ // mailboxUid -> when its last zero-access sync started. A mailbox syncs only once its build of this unlock settled (`builtRef` - the
63
+ // sync writes into the index the build opened), first at once and then every interval; a mailbox in `syncingRef` has a pass running,
64
+ // and never gets a second one alongside it. A lock forgets all three, so a pass still winding down never holds up the next unlock's.
62
65
  const lastSyncRef = useRef(new Map());
63
- const syncingRef = useRef(new Set());
64
- function syncIfDue(targetMailboxUid, targetClient, first = false) {
66
+ const builtRef = useRef(new Set());
67
+ // mailboxUid -> its latest build, so only that one's end starts the sync (not one a lock overtook).
68
+ const buildsRef = useRef(new Map());
69
+ const syncingRef = useRef(new Map());
70
+ function syncIfDue(targetMailboxUid, targetClient) {
65
71
  const unlocked = getUnlockedKeys(targetMailboxUid);
66
72
  const last = lastSyncRef.current.get(targetMailboxUid);
67
- if (!unlocked?.zeroAccessKeys?.length || syncingRef.current.has(targetMailboxUid) || (!first && (last === undefined || Date.now() - last < ZERO_ACCESS_SYNC_INTERVAL_MS))) {
73
+ if (!unlocked?.zeroAccessKeys?.length ||
74
+ !builtRef.current.has(targetMailboxUid) ||
75
+ syncingRef.current.has(targetMailboxUid) ||
76
+ (last !== undefined && Date.now() - last < ZERO_ACCESS_SYNC_INTERVAL_MS)) {
68
77
  return;
69
78
  }
70
79
  lastSyncRef.current.set(targetMailboxUid, Date.now());
71
- syncingRef.current.add(targetMailboxUid);
80
+ const pass = Symbol(targetMailboxUid);
81
+ syncingRef.current.set(targetMailboxUid, pass);
72
82
  loadSyncer()
73
83
  .then(({ syncZeroAccessIndex }) => syncZeroAccessIndex(targetMailboxUid, unlocked, targetClient))
74
84
  .catch(() => undefined)
75
- .finally(() => syncingRef.current.delete(targetMailboxUid));
85
+ .finally(() => {
86
+ if (syncingRef.current.get(targetMailboxUid) === pass) {
87
+ syncingRef.current.delete(targetMailboxUid);
88
+ }
89
+ });
76
90
  }
77
91
  function startBuildIfUnlocked(targetMailboxUid, targetFolders, targetClient) {
78
92
  const unlocked = getUnlockedKeys(targetMailboxUid);
@@ -80,33 +94,41 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
80
94
  return;
81
95
  }
82
96
  trackedRef.current.set(targetMailboxUid, true);
97
+ const build = Symbol(targetMailboxUid);
98
+ buildsRef.current.set(targetMailboxUid, build);
83
99
  // Never an unhandled rejection - a broken local index is best-effort infrastructure, not a build
84
100
  // the rest of the app depends on.
85
- loadBuilder()
101
+ // The sync runs however the build ended: zero-access mail reaches the index only through it, and a build that failed part-way
102
+ // isn't tried again until the next unlock. (Locked meanwhile, there are no keys to sync with.)
103
+ void loadBuilder()
86
104
  .then(({ buildLocalIndex }) => buildLocalIndex(targetMailboxUid, unlocked, targetFolders, undefined, targetClient))
87
- .then(() => syncIfDue(targetMailboxUid, targetClient, true))
88
- .catch(() => undefined);
105
+ .catch(() => undefined)
106
+ .then(() => {
107
+ if (buildsRef.current.get(targetMailboxUid) === build && trackedRef.current.get(targetMailboxUid)) {
108
+ builtRef.current.add(targetMailboxUid);
109
+ syncIfDue(targetMailboxUid, targetClient);
110
+ }
111
+ });
89
112
  }
90
113
  useEffect(() => {
91
114
  if (mailboxUid) {
92
115
  startBuildIfUnlocked(mailboxUid, folders, client);
93
116
  }
94
117
  }, [mailboxUid, folders, client]);
118
+ // A lock forgets the mailbox at once: no build of this unlock counts as built any more (the cancelled build's end finds `buildsRef`
119
+ // cleared and starts no sync), and the next unlock's tick starts a new build. The cancel and destroy themselves are
120
+ // `localIndexRpcClient.ts`'s, which reacts to the same event.
121
+ useEffect(() => subscribeKeySession(({ mailboxUid: lockedUid, state }) => {
122
+ if (state === "locked" && trackedRef.current.get(lockedUid)) {
123
+ trackedRef.current.set(lockedUid, false);
124
+ lastSyncRef.current.delete(lockedUid);
125
+ builtRef.current.delete(lockedUid);
126
+ syncingRef.current.delete(lockedUid);
127
+ buildsRef.current.delete(lockedUid);
128
+ }
129
+ }), []);
95
130
  useEffect(() => {
96
131
  const interval = setInterval(() => {
97
- for (const [trackedUid, wasUnlocked] of trackedRef.current) {
98
- if (wasUnlocked && !getUnlockedKeys(trackedUid)) {
99
- // A present-to-absent transition: something just destroyed this mailbox's keys.
100
- trackedRef.current.set(trackedUid, false);
101
- lastSyncRef.current.delete(trackedUid);
102
- // Stop the pass still running with the now-destroyed keys; the destroy itself also makes
103
- // the Worker reject anything that pass still sends.
104
- loadBuilder()
105
- .then(({ cancelLocalIndexBuild }) => cancelLocalIndexBuild(trackedUid))
106
- .catch(() => undefined);
107
- void destroyLocalIndex(trackedUid);
108
- }
109
- }
110
132
  // The active mailbox was (re-)unlocked since the last check, e.g. via an on-demand unlock prompt.
111
133
  const { mailboxUid: activeUid, folders: activeFolders, client: activeClient } = latestRef.current;
112
134
  if (activeUid) {
@@ -15,7 +15,7 @@
15
15
  * cross-folder interleaving imprecision here does not affect *what* ultimately survives the window, only
16
16
  * the exact order entities are inserted (and therefore briefly evicted-and-reinserted) in.
17
17
  *
18
- * Only encrypted messages (`subject === "[...]"`) are indexed - see `ENCRYPTED_SUBJECT_PLACEHOLDER`'s
18
+ * Only encrypted messages (`subject === "[...]"`, or flagged `encrypted` by the server - OpenPGP mail keeps its real outer subject) are indexed - see `ENCRYPTED_SUBJECT_PLACEHOLDER`'s
19
19
  * own precedent in `apps/www/index.tsx`'s inbox-list decrypt work. An unencrypted message is already
20
20
  * fully searchable via Tier 1; indexing it here too would spend this index's bounded byte budget on
21
21
  * content that didn't need it.
@@ -58,6 +58,9 @@ export declare const MAX_WALK_ATTEMPTS = 3;
58
58
  /** Subtracted from the pass's start time before it's recorded as the end of guaranteed coverage - a
59
59
  * message's server-assigned `receivedDate` can be ahead of this device's clock. */
60
60
  export declare const CLOCK_SKEW_MARGIN_MS: number;
61
+ /** Decrypted end-to-end messages are uploaded for the mailbox's other devices (`uploadClientDocuments()`) whenever this many are waiting,
62
+ * not all at the end of the pass: a pass cut short keeps what it shared, and the plaintext isn't all held in memory at once. */
63
+ export declare const CLIENT_UPLOAD_BATCH = 500;
61
64
  /** How many raw-MIME fetch+decrypt calls run at once - a page of 100 encrypted messages used to fire all
62
65
  * 100 requests simultaneously. */
63
66
  export declare const FETCH_CONCURRENCY = 6;
@@ -19,7 +19,7 @@
19
19
  * cross-folder interleaving imprecision here does not affect *what* ultimately survives the window, only
20
20
  * the exact order entities are inserted (and therefore briefly evicted-and-reinserted) in.
21
21
  *
22
- * Only encrypted messages (`subject === "[...]"`) are indexed - see `ENCRYPTED_SUBJECT_PLACEHOLDER`'s
22
+ * Only encrypted messages (`subject === "[...]"`, or flagged `encrypted` by the server - OpenPGP mail keeps its real outer subject) are indexed - see `ENCRYPTED_SUBJECT_PLACEHOLDER`'s
23
23
  * own precedent in `apps/www/index.tsx`'s inbox-list decrypt work. An unencrypted message is already
24
24
  * fully searchable via Tier 1; indexing it here too would spend this index's bounded byte budget on
25
25
  * content that didn't need it.
@@ -42,12 +42,14 @@
42
42
  */
43
43
  import { getMessageRawContent, listFolders, listMessages } from "../../../lib/mail/mailApi.js";
44
44
  import { evaluateMessageSecurity, evaluateMessageSecurityWithSeal } from "../../../lib/crypto/messageSecurity.js";
45
- import { getPinnedSignerFingerprints } from "../components/mail/pinnedSigners.js";
45
+ import { KeysLockedError } from "../../../lib/crypto/masterKey.js";
46
+ import { getPinnedSigners } from "../components/mail/pinnedSigners.js";
46
47
  import { currentVerificationSeal, readVaultGeneration, sendVerificationSeal, verificationSealPending } from "../components/mail/verificationSeals.js";
48
+ import { messageRowFlags, messageRowVersion } from "./localIndexSchema.js";
47
49
  import { getIndexedVersions, moveLocalEntity, indexLocalEntities, initLocalIndex, nextLocalIndexGeneration, pruneLocalEntities, setLocalIndexBuilding, setLocalIndexWindow, } from "./localIndexRpcClient.js";
48
50
  import { deriveLocalIndexKey } from "./localIndexKey.js";
49
51
  import { getLocalIndexByteBudget } from "./localIndexSizePreference.js";
50
- import { importClientSegments, uploadClientDocuments } from "./zeroAccessIndexSync.js";
52
+ import { importClientSegments, rememberZeroAccessMetadata, uploadClientDocuments } from "./zeroAccessIndexSync.js";
51
53
  /** The RFC 9788 placeholder subject every encrypted message's outer envelope carries server-side - see
52
54
  * `apps/www/index.tsx`'s identical constant and its own doc comment for the full citation. */
53
55
  const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
@@ -72,6 +74,9 @@ export const MAX_WALK_ATTEMPTS = 3;
72
74
  * message's server-assigned `receivedDate` can be ahead of this device's clock. */
73
75
  export const CLOCK_SKEW_MARGIN_MS = 10 * 60 * 1000;
74
76
  const PAGE_SIZE = 100;
77
+ /** Decrypted end-to-end messages are uploaded for the mailbox's other devices (`uploadClientDocuments()`) whenever this many are waiting,
78
+ * not all at the end of the pass: a pass cut short keeps what it shared, and the plaintext isn't all held in memory at once. */
79
+ export const CLIENT_UPLOAD_BATCH = 500;
75
80
  const MAX_APPROX_BYTES_PER_MESSAGE_PADDING = 512; // subject/participants/flags overhead beyond raw text length
76
81
  const FETCH_FAILED = Symbol("fetch-failed");
77
82
  /** How many raw-MIME fetch+decrypt calls run at once - a page of 100 encrypted messages used to fire all
@@ -94,7 +99,7 @@ function createPassSealer(mailboxUid, unlocked, signal, client) {
94
99
  const key = address.toLowerCase();
95
100
  let entry = pins.get(key);
96
101
  if (!entry) {
97
- entry = getPinnedSignerFingerprints(mailboxUid, address, client).then((fingerprints) => (fingerprints.length > 0 ? fingerprints : undefined), () => undefined);
102
+ entry = getPinnedSigners(mailboxUid, address, client).then((signers) => signers.openPgpKeys.length > 0 ? signers : signers.fingerprints.length > 0 ? signers.fingerprints : undefined, () => undefined);
98
103
  pins.set(key, entry);
99
104
  }
100
105
  return entry;
@@ -159,11 +164,8 @@ function estimateByteSize(entity) {
159
164
  const textBytes = [entity.subject, entity.body, entity.attachmentText, entity.participants].reduce((total, value) => total + (value ? encoder.encode(value).length : 0), 0);
160
165
  return textBytes + MAX_APPROX_BYTES_PER_MESSAGE_PADDING;
161
166
  }
162
- /** The change marker stored per row: a new server `version` (flags, content) or a folder move both
163
- * invalidate the indexed copy. */
164
- function versionOf(message) {
165
- return `${message.version}:${message.folderUid}`;
166
- }
167
+ const versionOf = (message) => messageRowVersion(message);
168
+ const entityFlags = (message) => messageRowFlags(message);
167
169
  /** `Promise.all(items.map(fn))`, but with at most `limit` calls in flight. Preserves input order. */
168
170
  async function mapWithConcurrency(items, limit, fn) {
169
171
  const results = new Array(items.length);
@@ -202,12 +204,7 @@ async function buildEntity(message, sealer) {
202
204
  const participants = [message.from.address, message.from.displayName, ...message.recipients.map((r) => r.address)]
203
205
  .filter(Boolean)
204
206
  .join(" ");
205
- const setFlags = Object.entries(message.flags)
206
- .filter(([, value]) => value)
207
- .map(([key]) => key);
208
- // Leading/trailing comma so `flags LIKE '%,x,%'` (localIndexSchema.ts's buildSearchPredicates())
209
- // matches correctly even for the first/last flag in the list.
210
- const flags = `,${setFlags.join(",")},`;
207
+ const flags = entityFlags(message);
211
208
  const entity = {
212
209
  entityType: "message",
213
210
  entityUid: message.uid,
@@ -290,6 +287,11 @@ async function runBuildPass(mailboxUid, unlocked, folders, windowConfig, signal,
290
287
  const generation = nextLocalIndexGeneration();
291
288
  const indexKey = await deriveLocalIndexKey(unlocked.masterKey, mailboxUid);
292
289
  signal.throwIfAborted();
290
+ // A lock during the derive doesn't fail it (WebCrypto copied the master key first), and the index client only counts locks from
291
+ // `initLocalIndex()`'s own call on: open nothing with the destroyed keys.
292
+ if (unlocked.destroyed) {
293
+ throw new KeysLockedError();
294
+ }
293
295
  await initLocalIndex({ mailboxUid, indexKey, generation });
294
296
  let { evictedBefore } = await setLocalIndexWindow(mailboxUid, windowConfig.timeFloorMonths, windowConfig.byteBudgetBytes, generation);
295
297
  await setLocalIndexBuilding(mailboxUid, true, { complete: false, generation });
@@ -316,8 +318,10 @@ async function runBuildPass(mailboxUid, unlocked, folders, windowConfig, signal,
316
318
  .filter((f) => MESSAGE_FOLDER_TYPES.has(f.type))
317
319
  .sort((a, b) => (FOLDER_PRIORITY[a.type] ?? 99) - (FOLDER_PRIORITY[b.type] ?? 99));
318
320
  const seenEncryptedUids = new Set();
319
- // What this pass decrypted, for the mailbox's other devices.
321
+ // What this pass decrypted and hasn't shared yet, for the mailbox's other devices.
320
322
  const decrypted = [];
323
+ // Best effort: what fails to upload is decrypted again by the other devices themselves.
324
+ const share = () => uploadClientDocuments(mailboxUid, unlocked, decrypted.splice(0), client).catch(() => undefined);
321
325
  async function walkFolder(folder) {
322
326
  for (let page = 0;; page++) {
323
327
  signal.throwIfAborted();
@@ -331,17 +335,21 @@ async function runBuildPass(mailboxUid, unlocked, folders, windowConfig, signal,
331
335
  if (messages.length === 0) {
332
336
  return "done";
333
337
  }
334
- const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
338
+ // The server's `encrypted` flag too: an OpenPGP sender (Proton Mail and others) keeps the real outer subject.
339
+ const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER || m.encrypted);
335
340
  for (const m of encrypted) {
336
341
  seenEncryptedUids.add(m.uid);
337
342
  }
338
343
  // A zero-access message's content comes from the server's sealed index (`zeroAccessIndexSync.ts`), never decrypted here:
339
- // only its folder is recorded on its row - and remembered by version, so an unchanged one costs nothing next time.
344
+ // only its folder and flags are recorded on its row - and remembered by version, so an unchanged one costs nothing next
345
+ // time. Its row may not be imported yet (a new device, or after a lock): the sync applies them when it imports it.
340
346
  const sealed = encrypted.filter((m) => m.zeroAccess);
341
347
  if (sealed.length > 0) {
342
348
  const indexed = await getIndexedVersions(mailboxUid, sealed.map((m) => m.uid));
343
349
  for (const m of sealed.filter((s) => indexed[s.uid] !== versionOf(s))) {
344
- await moveLocalEntity(mailboxUid, m.uid, m.folderUid, versionOf(m));
350
+ const metadata = { folderUid: m.folderUid, entityVersion: versionOf(m), flags: entityFlags(m) };
351
+ await moveLocalEntity(mailboxUid, m.uid, metadata.folderUid, metadata.entityVersion, metadata.flags);
352
+ rememberZeroAccessMetadata(mailboxUid, m.uid, metadata);
345
353
  }
346
354
  }
347
355
  // Anything older than the eviction watermark would only be inserted and evicted again.
@@ -360,6 +368,9 @@ async function runBuildPass(mailboxUid, unlocked, folders, windowConfig, signal,
360
368
  if (entities.length > 0) {
361
369
  ({ evictedBefore } = await indexLocalEntities(mailboxUid, entities, generation));
362
370
  decrypted.push(...entities);
371
+ if (decrypted.length >= CLIENT_UPLOAD_BATCH) {
372
+ await share();
373
+ }
363
374
  }
364
375
  }
365
376
  const oldestOnPage = messages[messages.length - 1].receivedDate;
@@ -418,7 +429,7 @@ async function runBuildPass(mailboxUid, unlocked, folders, windowConfig, signal,
418
429
  const since = evictedBefore && isWithinTimeFloor(evictedBefore, cutoff) ? evictedBefore : cutoff?.toISOString();
419
430
  await pruneLocalEntities(mailboxUid, [...seenEncryptedUids], since, { folderUids: reliable, generation });
420
431
  }
421
- await uploadClientDocuments(mailboxUid, unlocked, decrypted, client).catch(() => undefined);
432
+ await share();
422
433
  }
423
434
  catch (err) {
424
435
  complete = false;
@@ -1,39 +1,3 @@
1
- /**
2
- * Main-thread Promise-based RPC wrapper around `localIndexWorker.ts`. One Worker per tab (spawned
3
- * lazily, on first use, not at module load - a page that never touches search shouldn't pay for booting
4
- * the WASM module at all), shared across every mailbox `init()`-ed this session - the Worker itself keeps
5
- * a per-mailbox connection map (see that file's own doc comment).
6
- *
7
- * `postMessage` has no native request/response pairing, so every call generates its own numeric `id` and
8
- * this module correlates the eventual matching response via a pending-request map.
9
- *
10
- * **Generations**: `nextLocalIndexGeneration()` hands out one monotonic counter shared by build passes and
11
- * destroys, so the Worker can reject a build call issued before the index was destroyed (see
12
- * `GenerationParams`).
13
- *
14
- * **Sign-out reaches every tab**: `destroyAllLocalIndexes()` broadcasts on a `BroadcastChannel`; any other
15
- * tab running a Worker closes its connections and refuses further `init`s.
16
- *
17
- * **Failed deletions are retried**: a destroy that couldn't remove a directory (another tab held it, the page
18
- * navigated away first) is recorded in `localStorage` and retried before this tab's first `init`.
19
- *
20
- * **A crashed Worker never leaves a caller hanging**: the `error` listener registered in `getWorker()` rejects
21
- * every pending request and drops the module-level `worker` reference so the next `call()` spawns a
22
- * replacement. `searchTier2.ts` additionally races its own calls against a timeout, since a Worker that's
23
- * merely stuck (not crashed) posts no `error` event at all.
24
- *
25
- * **Swappable transport**: everything above is specific to this one implementation - the Worker/OPFS-backed
26
- * default. Every function this module exports below (`initLocalIndex`, `searchLocal`, `destroyLocalIndex`, ...)
27
- * is a thin delegator to whichever `LocalIndexTransport` (`localIndexTransport.ts`) is "current," which defaults
28
- * to `workerLocalIndexTransport` - this file's own implementation, wrapped - and can be swapped once, at startup,
29
- * via `setLocalIndexTransport()`. A host app with a different storage backend (no Worker, no OPFS, no WASM at
30
- * all - `tauri-client`'s native SQLCipher/`rusqlite` index) installs its own transport there; every call site
31
- * that already goes through this file's exports (`searchTier2.ts`, `LocalIndexLifecycle.tsx`,
32
- * `localIndexBuilder.ts`, and the rest) then runs against that backend instead, unmodified. The three
33
- * module-level constants below (`SIGN_OUT_CHANNEL`, `PENDING_DELETIONS_KEY`, `DESTROY_ALL_TIMEOUT_MS`) remain
34
- * plain exports, not part of the swappable interface - they name this one transport's own cross-tab/localStorage
35
- * mechanics, which a different transport has no reason to share.
36
- */
37
1
  import type { ParsedSearchQuery } from "../../../lib/search/queryGrammar.js";
38
2
  import type { Coverage, IndexEntitiesResult, InitParams, LocalSearchPage, WindowState } from "./localIndexWorker.js";
39
3
  import type { LocalIndexEntity } from "./localIndexSchema.js";
@@ -65,8 +29,9 @@ export declare function indexLocalEntities(mailboxUid: string, entities: LocalIn
65
29
  * this tab - a later build pass prunes it instead. Never rejects: best-effort housekeeping. */
66
30
  export declare function removeLocalEntity(mailboxUid: string, entityUid: string): Promise<void>;
67
31
  /** Re-points one indexed message at its new folder (archive, cancel-scheduled-send) so `folder:`-scoped
68
- * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntity()`. */
69
- export declare function moveLocalEntity(mailboxUid: string, entityUid: string, folderUid: string, entityVersion?: string): Promise<void>;
32
+ * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntity()`. `flags` (`,flag,flag,`) sets the row's flags
33
+ * too - a zero-access row's, which nothing else records. */
34
+ export declare function moveLocalEntity(mailboxUid: string, entityUid: string, folderUid: string, entityVersion?: string, flags?: string): Promise<void>;
70
35
  export declare function getIndexedVersions(mailboxUid: string, entityUids: string[]): Promise<Record<string, string>>;
71
36
  export declare function pruneLocalEntities(mailboxUid: string, keepEntityUids: string[], since: string | undefined, options?: PruneLocalEntitiesOptions): Promise<number>;
72
37
  export declare function searchLocal(mailboxUid: string, parsed: ParsedSearchQuery, limit: number, offset?: number): Promise<LocalSearchPage>;
@@ -82,7 +47,8 @@ export declare function destroyLocalIndex(mailboxUid: string): Promise<boolean>;
82
47
  * on sign-out. Never rejects. Resolves `true` only if everything was removed within `timeoutMs`. */
83
48
  export declare function destroyAllLocalIndexes(timeoutMs?: number): Promise<boolean>;
84
49
  /** Removes local indexes for mailboxes outside `accessibleMailboxUids` - e.g. left behind by a different
85
- * user who signed in on this device without signing out. Never rejects. */
50
+ * user who signed in on this device without signing out. The transport decides what goes, zero-access sync state included (the
51
+ * Worker transport forgets it with the index; a host wrapper that skips the prune keeps both). Never rejects. */
86
52
  export declare function pruneInaccessibleLocalIndexes(accessibleMailboxUids: Iterable<string>): Promise<void>;
87
53
  /** Retries every deletion an earlier session couldn't finish. Never rejects. */
88
54
  export declare function retryPendingLocalIndexDeletions(): Promise<void>;