@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 { Message, cancelScheduledSend, getMessage, listAttachments, listFolders, queueMessageSend } from "../../../../lib/mail/mailApi.js";
5
+ import { Message, cancelScheduledSend, deleteMessage, getMessage, listAttachments, listFolders, queueMessageSend } from "../../../../lib/mail/mailApi.js";
6
6
  import { SendEvent, describeSendEventError } from "../../../../lib/mail/sendEvents.js";
7
7
  import type { ApiClient } from "../../../../lib/util/api.js";
8
8
  import { formatRecipient } from "../../components/mail/compose/recipients.js";
@@ -12,7 +12,18 @@ import { dismiss, notify } from "../../notifications/store.js";
12
12
  import { beginPendingSend, finishPendingSend, setPendingStage } from "./pendingSends.js";
13
13
  import { failOutgoing, forgetOutgoing, markOutgoingSending, markOutgoingSent } from "./outgoingReplies.js";
14
14
  import { openComposeFromOutside } from "./composeBridge.js";
15
- import { describeMessage, forgetRetainedRequest, notifySent, openDraftFromRequest, retainedRequest } from "./sendJob.js";
15
+ import {
16
+ SendRequest,
17
+ copyFailed,
18
+ copyRetried,
19
+ describeMessage,
20
+ forgetCopies,
21
+ forgetRetainedRequest,
22
+ notifySent,
23
+ openDraftFromRequest,
24
+ retainedRequest,
25
+ unsentCopies,
26
+ } from "./sendJob.js";
16
27
 
17
28
  /**
18
29
  * What the user is told when the server reports how a queued message went (`send-succeeded`, `send-failed`, `send-retrying` push events,
@@ -43,14 +54,42 @@ function escapeHtml(text: string): string {
43
54
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
44
55
  }
45
56
 
46
- /** "Open draft" for a message the server failed to relay: back into Drafts, and into a compose window with what was written. */
47
- async function openFailedDraft(event: SendEvent, client: ApiClient | undefined): Promise<void> {
57
+ /** The message a failure is about: as the server reports it (a `SendEvent`), or as this tab sent it. */
58
+ export type FailedSend = Pick<SendEvent, "uid" | "mailboxUid" | "subject" | "recipients">;
59
+
60
+ /** "Open draft" for a message the server failed to relay: back into Drafts, and into a compose window with what was written. `request`: the
61
+ * one it was queued from, when this tab holds it. */
62
+ async function openFailedDraft(event: FailedSend, client: ApiClient | undefined, request: SendRequest | undefined): Promise<void> {
63
+ // A message sent as several copies (one per encryption format) is written again only when none of them went: while another copy has
64
+ // gone or may still go, the whole message sent again would reach its recipients a second time - however the action came to be offered.
65
+ const copies = unsentCopies(request);
66
+ if (request?.copyFates && !copies) {
67
+ notify({
68
+ id: `open-draft:${event.uid}`,
69
+ kind: "info",
70
+ title: "Only this copy didn't go",
71
+ message: "The copy for its other recipients is sent on its own. Use Retry to send this one to its own recipients.",
72
+ timeoutMs: 8_000,
73
+ });
74
+ return;
75
+ }
48
76
  try {
49
- const moved = await moveBackToDrafts(event.uid, event.mailboxUid ?? "", client);
77
+ // Of a message sent as several copies, its own draft is the one written again.
78
+ const moved = await moveBackToDrafts(copies ? request!.draft.uid : event.uid, event.mailboxUid ?? "", client);
50
79
  // Back in Drafts it is no longer a message on its way in its conversation.
51
80
  forgetOutgoing(event.uid);
52
- const request = retainedRequest(event.uid);
53
81
  if (request) {
82
+ // 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
83
+ // be retried meanwhile.
84
+ for (const uid of copies?.filter((uid) => uid !== request.draft.uid) ?? []) {
85
+ const extra = await moveBackToDrafts(uid, event.mailboxUid ?? "", client);
86
+ await deleteMessage(extra.uid, extra.version, client);
87
+ dismiss(failedId(uid));
88
+ forgetOutgoing(uid);
89
+ }
90
+ if (request.copyFates) {
91
+ forgetCopies(request.copyFates);
92
+ }
54
93
  openDraftFromRequest({ ...request, draft: moved });
55
94
  return;
56
95
  }
@@ -88,20 +127,24 @@ async function openFailedDraft(event: SendEvent, client: ApiClient | undefined):
88
127
  }
89
128
  }
90
129
 
91
- /** "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). */
92
- async function retryFailed(event: SendEvent, client: ApiClient | undefined): Promise<void> {
130
+ /** "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).
131
+ * `request`: the one it was queued from, when this tab holds it - a copy of a message sent as several is then awaited again. */
132
+ async function retryFailed(event: FailedSend, client: ApiClient | undefined, request: SendRequest | undefined): Promise<void> {
93
133
  if (!beginPendingSend({ draftUid: event.uid, mailboxUid: event.mailboxUid ?? "", subject: event.subject, recipients: event.recipients, scheduled: false })) {
94
134
  return;
95
135
  }
96
136
  dismiss(failedId(event.uid));
97
137
  markOutgoingSending(event.uid);
138
+ copyRetried(request, event.uid);
98
139
  try {
99
140
  setPendingStage(event.uid, "queuing");
100
141
  const result = await queueMessageSend(event.uid, client);
101
- if (!result.queued) {
142
+ // Relayed before the server answered - and so, when it is one copy of several, maybe the last of them.
143
+ if (!result.queued && forgetRetainedRequest(event.uid)) {
102
144
  notifySent();
103
145
  }
104
146
  } catch (err) {
147
+ copyFailed(request, event.uid);
105
148
  notifyApiError(err, "This message wasn't sent", { dedupeKey: failedId(event.uid) });
106
149
  } finally {
107
150
  finishPendingSend(event.uid);
@@ -114,26 +157,29 @@ async function retryFailed(event: SendEvent, client: ApiClient | undefined): Pro
114
157
  export function handleSendEvent(event: SendEvent, client?: ApiClient): void {
115
158
  if (event.action === "send-succeeded") {
116
159
  dismiss(retryId(event.uid));
117
- dismiss(failedId(event.uid));
160
+ // 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:
161
+ // it stays, with the Retry that hands that copy over.
162
+ if (![...(retainedRequest(event.uid)?.copyFates?.values() ?? [])].includes("unsent")) {
163
+ dismiss(failedId(event.uid));
164
+ }
118
165
  // The conversation on screen swaps the message it drew for the Sent Items copy (a message this tab did not send has nothing drawn).
119
166
  markOutgoingSent(event.uid);
120
167
  // The server reports every message it relays, scheduled ones from long ago included: only a message this tab queued in the background is
121
168
  // worth a confirmation (several are one pop-up counting up). Anything else - a send-later message going out, another tab's send - is quiet.
122
- if (retainedRequest(event.uid)) {
123
- forgetRetainedRequest(event.uid);
169
+ // A message sent as several copies (one per encryption format) is confirmed once, when the last of them has gone.
170
+ if (retainedRequest(event.uid) && forgetRetainedRequest(event.uid)) {
124
171
  notifySent();
125
172
  }
126
173
  return;
127
174
  }
128
175
  const failure = describeSendEventError(event.error, event.action === "send-failed" ? "The server couldn't send this message." : "The server couldn't send this message yet.");
129
- const what = describeMessage(event.subject, summary(event.recipients));
130
176
  if (event.action === "send-retrying") {
131
177
  const next = event.nextAttemptAt ? Date.parse(event.nextAttemptAt) : NaN;
132
178
  notify({
133
179
  id: retryId(event.uid),
134
180
  kind: "info",
135
181
  title: "Retrying to send",
136
- message: `${what} (attempt ${event.attempt + 1}${Number.isNaN(next) ? "" : `, at ${new Date(next).toLocaleTimeString()}`}). ${failure.message}`,
182
+ message: `${describeMessage(event.subject, summary(event.recipients))} (attempt ${event.attempt + 1}${Number.isNaN(next) ? "" : `, at ${new Date(next).toLocaleTimeString()}`}). ${failure.message}`,
137
183
  details: failure.lines,
138
184
  sticky: false,
139
185
  timeoutMs: 8_000,
@@ -141,18 +187,43 @@ export function handleSendEvent(event: SendEvent, client?: ApiClient): void {
141
187
  return;
142
188
  }
143
189
  dismiss(retryId(event.uid));
144
- const actions = [
145
- { label: "Retry", onClick: () => void retryFailed(event, client) },
146
- { label: "Open draft", onClick: () => void openFailedDraft(event, client) },
147
- ];
190
+ const request = retainedRequest(event.uid);
191
+ copyFailed(request, event.uid);
192
+ notifySendFailure(event, failure, request, client);
193
+ }
194
+
195
+ /**
196
+ * 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
197
+ * 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
198
+ * server had failed one (`sendJob.ts`'s `notifyFailure()`). `request`: the one it was queued from, when this tab holds it.
199
+ */
200
+ export function notifySendFailure(event: FailedSend, failure: { message: string; lines: string[] }, request: SendRequest | undefined, client: ApiClient | undefined): void {
201
+ // Every copy of a message sent as several (one per encryption format, `SendRequest.copyUids`) failed or never went: nobody has it, and it
202
+ // is the whole message's failure - one pop-up, whose Retry hands each copy over again and whose "Open draft" writes the message again.
203
+ const copies = unsentCopies(request);
204
+ // One copy of several, while another has gone or may still go: the others' recipients have theirs or will, and "Open draft" would send
205
+ // the whole message to them again - Retry sends just this copy, to just its own recipients.
206
+ const oneCopy = !!request?.copyFates && !copies;
207
+ const retry = () => {
208
+ for (const uid of copies ?? [event.uid]) {
209
+ void retryFailed({ ...event, uid }, client, request);
210
+ }
211
+ };
212
+ const actions = [{ label: "Retry", onClick: retry }, ...(oneCopy ? [] : [{ label: "Open draft", onClick: () => void openFailedDraft(event, client, request) }])];
213
+ const message: string = oneCopy ? `The copy for some of its recipients didn't go - the others' copy is sent on its own. ${failure.message}` : failure.message;
214
+ // What an earlier copy's failure said of the others is no longer so: this pop-up stands for the whole message.
215
+ for (const uid of copies?.filter((uid) => uid !== event.uid) ?? []) {
216
+ dismiss(failedId(uid));
217
+ failOutgoing(uid, message, actions);
218
+ }
148
219
  notify({
149
220
  id: failedId(event.uid),
150
221
  kind: "error",
151
222
  title: "This message wasn't sent",
152
- message: `${what}: ${failure.message}`,
223
+ message: `${describeMessage(event.subject, summary(event.recipients))}: ${message}`,
153
224
  details: failure.lines,
154
225
  actions,
155
226
  });
156
227
  // The conversation on screen shows the same failure under the message, with the same ways out.
157
- failOutgoing(event.uid, failure.message, actions);
228
+ failOutgoing(event.uid, message, actions);
158
229
  }
@@ -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 type { PublicKey } from "../../../lib/crypto/keyvaultApi.js";
26
29
  import type { Folder } from "../../../lib/mail/mailApi.js";
27
30
  import type { ApiClient } from "../../../lib/util/api.js";
@@ -46,7 +49,7 @@ const loadSyncer = () =>
46
49
  syncer = undefined;
47
50
  throw err;
48
51
  }));
49
- import { destroyLocalIndex, pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
52
+ import { pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
50
53
 
51
54
  export const POLL_INTERVAL_MS = 5_000;
52
55
 
@@ -68,30 +71,43 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
68
71
  // apps, Electron), which keeps building the index against the default global cookie-based session exactly as before. A native,
69
72
  // multi-account host that renders this under an ApiClientContext.Provider gets its own account's client threaded through instead.
70
73
  const client = useApiClient();
71
- // mailboxUid -> whether it was unlocked at the last check. An entry exists for every mailbox whose
72
- // build this component started; only ever changed by a real observed transition, never reset by an
73
- // effect re-run.
74
+ // mailboxUid -> whether it is unlocked as far as this component knows: set when its build starts, cleared by its lock. An entry exists
75
+ // for every mailbox whose build this component started; never reset by an effect re-run.
74
76
  const trackedRef = useRef(new Map<string, boolean>());
75
77
  // The latest props (and client), for the long-lived polling interval below.
76
78
  const latestRef = useRef({ mailboxUid, folders, client });
77
79
  latestRef.current = { mailboxUid, folders, client };
78
- // mailboxUid -> when its last zero-access sync started. Set only once its first build finished (the sync writes into the index the
79
- // build opened); a mailbox in `syncingRef` has a pass running, and never gets a second one alongside it.
80
+ // mailboxUid -> when its last zero-access sync started. A mailbox syncs only once its build of this unlock settled (`builtRef` - the
81
+ // sync writes into the index the build opened), first at once and then every interval; a mailbox in `syncingRef` has a pass running,
82
+ // 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.
80
83
  const lastSyncRef = useRef(new Map<string, number>());
81
- const syncingRef = useRef(new Set<string>());
84
+ const builtRef = useRef(new Set<string>());
85
+ // mailboxUid -> its latest build, so only that one's end starts the sync (not one a lock overtook).
86
+ const buildsRef = useRef(new Map<string, symbol>());
87
+ const syncingRef = useRef(new Map<string, symbol>());
82
88
 
83
- function syncIfDue(targetMailboxUid: string, targetClient: ApiClient | undefined, first = false) {
89
+ function syncIfDue(targetMailboxUid: string, targetClient: ApiClient | undefined) {
84
90
  const unlocked = getUnlockedKeys(targetMailboxUid);
85
91
  const last = lastSyncRef.current.get(targetMailboxUid);
86
- if (!unlocked?.zeroAccessKeys?.length || syncingRef.current.has(targetMailboxUid) || (!first && (last === undefined || Date.now() - last < ZERO_ACCESS_SYNC_INTERVAL_MS))) {
92
+ if (
93
+ !unlocked?.zeroAccessKeys?.length ||
94
+ !builtRef.current.has(targetMailboxUid) ||
95
+ syncingRef.current.has(targetMailboxUid) ||
96
+ (last !== undefined && Date.now() - last < ZERO_ACCESS_SYNC_INTERVAL_MS)
97
+ ) {
87
98
  return;
88
99
  }
89
100
  lastSyncRef.current.set(targetMailboxUid, Date.now());
90
- syncingRef.current.add(targetMailboxUid);
101
+ const pass = Symbol(targetMailboxUid);
102
+ syncingRef.current.set(targetMailboxUid, pass);
91
103
  loadSyncer()
92
104
  .then(({ syncZeroAccessIndex }) => syncZeroAccessIndex(targetMailboxUid, unlocked, targetClient))
93
105
  .catch(() => undefined)
94
- .finally(() => syncingRef.current.delete(targetMailboxUid));
106
+ .finally(() => {
107
+ if (syncingRef.current.get(targetMailboxUid) === pass) {
108
+ syncingRef.current.delete(targetMailboxUid);
109
+ }
110
+ });
95
111
  }
96
112
 
97
113
  function startBuildIfUnlocked(targetMailboxUid: string, targetFolders: Folder[], targetClient: ApiClient | undefined) {
@@ -100,12 +116,21 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
100
116
  return;
101
117
  }
102
118
  trackedRef.current.set(targetMailboxUid, true);
119
+ const build = Symbol(targetMailboxUid);
120
+ buildsRef.current.set(targetMailboxUid, build);
103
121
  // Never an unhandled rejection - a broken local index is best-effort infrastructure, not a build
104
122
  // the rest of the app depends on.
105
- loadBuilder()
123
+ // The sync runs however the build ended: zero-access mail reaches the index only through it, and a build that failed part-way
124
+ // isn't tried again until the next unlock. (Locked meanwhile, there are no keys to sync with.)
125
+ void loadBuilder()
106
126
  .then(({ buildLocalIndex }) => buildLocalIndex(targetMailboxUid, unlocked, targetFolders, undefined, targetClient))
107
- .then(() => syncIfDue(targetMailboxUid, targetClient, true))
108
- .catch(() => undefined);
127
+ .catch(() => undefined)
128
+ .then(() => {
129
+ if (buildsRef.current.get(targetMailboxUid) === build && trackedRef.current.get(targetMailboxUid)) {
130
+ builtRef.current.add(targetMailboxUid);
131
+ syncIfDue(targetMailboxUid, targetClient);
132
+ }
133
+ });
109
134
  }
110
135
 
111
136
  useEffect(() => {
@@ -114,21 +139,25 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
114
139
  }
115
140
  }, [mailboxUid, folders, client]);
116
141
 
142
+ // A lock forgets the mailbox at once: no build of this unlock counts as built any more (the cancelled build's end finds `buildsRef`
143
+ // cleared and starts no sync), and the next unlock's tick starts a new build. The cancel and destroy themselves are
144
+ // `localIndexRpcClient.ts`'s, which reacts to the same event.
145
+ useEffect(
146
+ () =>
147
+ subscribeKeySession(({ mailboxUid: lockedUid, state }) => {
148
+ if (state === "locked" && trackedRef.current.get(lockedUid)) {
149
+ trackedRef.current.set(lockedUid, false);
150
+ lastSyncRef.current.delete(lockedUid);
151
+ builtRef.current.delete(lockedUid);
152
+ syncingRef.current.delete(lockedUid);
153
+ buildsRef.current.delete(lockedUid);
154
+ }
155
+ }),
156
+ [],
157
+ );
158
+
117
159
  useEffect(() => {
118
160
  const interval = setInterval(() => {
119
- for (const [trackedUid, wasUnlocked] of trackedRef.current) {
120
- if (wasUnlocked && !getUnlockedKeys(trackedUid)) {
121
- // A present-to-absent transition: something just destroyed this mailbox's keys.
122
- trackedRef.current.set(trackedUid, false);
123
- lastSyncRef.current.delete(trackedUid);
124
- // Stop the pass still running with the now-destroyed keys; the destroy itself also makes
125
- // the Worker reject anything that pass still sends.
126
- loadBuilder()
127
- .then(({ cancelLocalIndexBuild }) => cancelLocalIndexBuild(trackedUid))
128
- .catch(() => undefined);
129
- void destroyLocalIndex(trackedUid);
130
- }
131
- }
132
161
  // The active mailbox was (re-)unlocked since the last check, e.g. via an on-demand unlock prompt.
133
162
  const { mailboxUid: activeUid, folders: activeFolders, client: activeClient } = latestRef.current;
134
163
  if (activeUid) {
@@ -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.
@@ -41,12 +41,13 @@
41
41
  * above), and fetching it only to seal would cost a download per message; they are sealed when opened.
42
42
  */
43
43
  import { getMessageRawContent, listFolders, listMessages, type Folder, type Message } from "../../../lib/mail/mailApi.js";
44
- import { type MessageSecurityResult, evaluateMessageSecurity, evaluateMessageSecurityWithSeal } from "../../../lib/crypto/messageSecurity.js";
44
+ import { type MessageSecurityResult, type SignerPinsInput, evaluateMessageSecurity, evaluateMessageSecurityWithSeal } from "../../../lib/crypto/messageSecurity.js";
45
45
  import type { UnlockedKeys } from "../../../lib/crypto/keySession.js";
46
+ import { KeysLockedError } from "../../../lib/crypto/masterKey.js";
46
47
  import type { ApiClient } from "../../../lib/util/api.js";
47
- import { getPinnedSignerFingerprints } from "../components/mail/pinnedSigners.js";
48
+ import { getPinnedSigners } from "../components/mail/pinnedSigners.js";
48
49
  import { currentVerificationSeal, readVaultGeneration, sendVerificationSeal, verificationSealPending } from "../components/mail/verificationSeals.js";
49
- import type { LocalIndexEntity } from "./localIndexSchema.js";
50
+ import { type LocalIndexEntity, messageRowFlags, messageRowVersion } from "./localIndexSchema.js";
50
51
  import {
51
52
  getIndexedVersions,
52
53
  moveLocalEntity,
@@ -59,7 +60,7 @@ import {
59
60
  } from "./localIndexRpcClient.js";
60
61
  import { deriveLocalIndexKey } from "./localIndexKey.js";
61
62
  import { getLocalIndexByteBudget } from "./localIndexSizePreference.js";
62
- import { importClientSegments, uploadClientDocuments } from "./zeroAccessIndexSync.js";
63
+ import { importClientSegments, rememberZeroAccessMetadata, uploadClientDocuments } from "./zeroAccessIndexSync.js";
63
64
 
64
65
  /** The RFC 9788 placeholder subject every encrypted message's outer envelope carries server-side - see
65
66
  * `apps/www/index.tsx`'s identical constant and its own doc comment for the full citation. */
@@ -98,6 +99,9 @@ export const MAX_WALK_ATTEMPTS = 3;
98
99
  export const CLOCK_SKEW_MARGIN_MS = 10 * 60 * 1000;
99
100
 
100
101
  const PAGE_SIZE = 100;
102
+ /** Decrypted end-to-end messages are uploaded for the mailbox's other devices (`uploadClientDocuments()`) whenever this many are waiting,
103
+ * 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. */
104
+ export const CLIENT_UPLOAD_BATCH = 500;
101
105
  const MAX_APPROX_BYTES_PER_MESSAGE_PADDING = 512; // subject/participants/flags overhead beyond raw text length
102
106
  const FETCH_FAILED = Symbol("fetch-failed");
103
107
 
@@ -121,7 +125,7 @@ interface PassSealer {
121
125
 
122
126
  function createPassSealer(mailboxUid: string, unlocked: UnlockedKeys, signal: AbortSignal, client: ApiClient | undefined): PassSealer {
123
127
  let generation: Promise<number | undefined> | undefined;
124
- const pins = new Map<string, Promise<string[] | undefined>>();
128
+ const pins = new Map<string, Promise<SignerPinsInput>>();
125
129
  const queue: (() => Promise<unknown>)[] = [];
126
130
  let queued = 0;
127
131
  let active = 0;
@@ -134,8 +138,9 @@ function createPassSealer(mailboxUid: string, unlocked: UnlockedKeys, signal: Ab
134
138
  const key = address.toLowerCase();
135
139
  let entry = pins.get(key);
136
140
  if (!entry) {
137
- entry = getPinnedSignerFingerprints(mailboxUid, address, client).then(
138
- (fingerprints) => (fingerprints.length > 0 ? fingerprints : undefined),
141
+ entry = getPinnedSigners(mailboxUid, address, client).then(
142
+ (signers): SignerPinsInput =>
143
+ signers.openPgpKeys.length > 0 ? signers : signers.fingerprints.length > 0 ? signers.fingerprints : undefined,
139
144
  () => undefined,
140
145
  );
141
146
  pins.set(key, entry);
@@ -210,11 +215,8 @@ function estimateByteSize(entity: Pick<LocalIndexEntity, "subject" | "body" | "a
210
215
  return textBytes + MAX_APPROX_BYTES_PER_MESSAGE_PADDING;
211
216
  }
212
217
 
213
- /** The change marker stored per row: a new server `version` (flags, content) or a folder move both
214
- * invalidate the indexed copy. */
215
- function versionOf(message: Message): string {
216
- return `${message.version}:${message.folderUid}`;
217
- }
218
+ const versionOf = (message: Message): string => messageRowVersion(message);
219
+ const entityFlags = (message: Message): string => messageRowFlags(message);
218
220
 
219
221
  /** `Promise.all(items.map(fn))`, but with at most `limit` calls in flight. Preserves input order. */
220
222
  async function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
@@ -254,12 +256,7 @@ async function buildEntity(message: Message, sealer: PassSealer): Promise<LocalI
254
256
  const participants = [message.from.address, message.from.displayName, ...message.recipients.map((r) => r.address)]
255
257
  .filter(Boolean)
256
258
  .join(" ");
257
- const setFlags = Object.entries(message.flags)
258
- .filter(([, value]) => value)
259
- .map(([key]) => key);
260
- // Leading/trailing comma so `flags LIKE '%,x,%'` (localIndexSchema.ts's buildSearchPredicates())
261
- // matches correctly even for the first/last flag in the list.
262
- const flags = `,${setFlags.join(",")},`;
259
+ const flags = entityFlags(message);
263
260
  const entity: LocalIndexEntity = {
264
261
  entityType: "message",
265
262
  entityUid: message.uid,
@@ -373,6 +370,11 @@ async function runBuildPass(
373
370
  const generation = nextLocalIndexGeneration();
374
371
  const indexKey = await deriveLocalIndexKey(unlocked.masterKey, mailboxUid);
375
372
  signal.throwIfAborted();
373
+ // A lock during the derive doesn't fail it (WebCrypto copied the master key first), and the index client only counts locks from
374
+ // `initLocalIndex()`'s own call on: open nothing with the destroyed keys.
375
+ if (unlocked.destroyed) {
376
+ throw new KeysLockedError();
377
+ }
376
378
  await initLocalIndex({ mailboxUid, indexKey, generation });
377
379
  let { evictedBefore } = await setLocalIndexWindow(mailboxUid, windowConfig.timeFloorMonths, windowConfig.byteBudgetBytes, generation);
378
380
  await setLocalIndexBuilding(mailboxUid, true, { complete: false, generation });
@@ -399,8 +401,10 @@ async function runBuildPass(
399
401
  .filter((f) => MESSAGE_FOLDER_TYPES.has(f.type))
400
402
  .sort((a, b) => (FOLDER_PRIORITY[a.type] ?? 99) - (FOLDER_PRIORITY[b.type] ?? 99));
401
403
  const seenEncryptedUids = new Set<string>();
402
- // What this pass decrypted, for the mailbox's other devices.
404
+ // What this pass decrypted and hasn't shared yet, for the mailbox's other devices.
403
405
  const decrypted: LocalIndexEntity[] = [];
406
+ // Best effort: what fails to upload is decrypted again by the other devices themselves.
407
+ const share = (): Promise<unknown> => uploadClientDocuments(mailboxUid, unlocked, decrypted.splice(0), client).catch(() => undefined);
404
408
 
405
409
  async function walkFolder(folder: Folder): Promise<WalkOutcome> {
406
410
  for (let page = 0; ; page++) {
@@ -414,12 +418,14 @@ async function runBuildPass(
414
418
  if (messages.length === 0) {
415
419
  return "done";
416
420
  }
417
- const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
421
+ // The server's `encrypted` flag too: an OpenPGP sender (Proton Mail and others) keeps the real outer subject.
422
+ const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER || m.encrypted);
418
423
  for (const m of encrypted) {
419
424
  seenEncryptedUids.add(m.uid);
420
425
  }
421
426
  // A zero-access message's content comes from the server's sealed index (`zeroAccessIndexSync.ts`), never decrypted here:
422
- // only its folder is recorded on its row - and remembered by version, so an unchanged one costs nothing next time.
427
+ // only its folder and flags are recorded on its row - and remembered by version, so an unchanged one costs nothing next
428
+ // time. Its row may not be imported yet (a new device, or after a lock): the sync applies them when it imports it.
423
429
  const sealed = encrypted.filter((m) => m.zeroAccess);
424
430
  if (sealed.length > 0) {
425
431
  const indexed = await getIndexedVersions(
@@ -427,7 +433,9 @@ async function runBuildPass(
427
433
  sealed.map((m) => m.uid),
428
434
  );
429
435
  for (const m of sealed.filter((s) => indexed[s.uid] !== versionOf(s))) {
430
- await moveLocalEntity(mailboxUid, m.uid, m.folderUid, versionOf(m));
436
+ const metadata = { folderUid: m.folderUid, entityVersion: versionOf(m), flags: entityFlags(m) };
437
+ await moveLocalEntity(mailboxUid, m.uid, metadata.folderUid, metadata.entityVersion, metadata.flags);
438
+ rememberZeroAccessMetadata(mailboxUid, m.uid, metadata);
431
439
  }
432
440
  }
433
441
  // Anything older than the eviction watermark would only be inserted and evicted again.
@@ -449,6 +457,9 @@ async function runBuildPass(
449
457
  if (entities.length > 0) {
450
458
  ({ evictedBefore } = await indexLocalEntities(mailboxUid, entities, generation));
451
459
  decrypted.push(...entities);
460
+ if (decrypted.length >= CLIENT_UPLOAD_BATCH) {
461
+ await share();
462
+ }
452
463
  }
453
464
  }
454
465
  const oldestOnPage = messages[messages.length - 1].receivedDate;
@@ -506,7 +517,7 @@ async function runBuildPass(
506
517
  const since = evictedBefore && isWithinTimeFloor(evictedBefore, cutoff) ? evictedBefore : cutoff?.toISOString();
507
518
  await pruneLocalEntities(mailboxUid, [...seenEncryptedUids], since, { folderUids: reliable, generation });
508
519
  }
509
- await uploadClientDocuments(mailboxUid, unlocked, decrypted, client).catch(() => undefined);
520
+ await share();
510
521
  } catch (err) {
511
522
  complete = false;
512
523
  throw err;