@rapidmx/web-client 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/calendarReminders.ts +35 -0
  3. package/apps/shared/calendar/sealedEvents.ts +113 -0
  4. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  5. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  6. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +89 -3
  7. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  8. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  9. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  10. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  11. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  12. package/apps/shared/components/calendar/eventForm.ts +2 -0
  13. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  15. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  16. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  17. package/apps/shared/components/contacts/contactCardData.ts +8 -4
  18. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  19. package/apps/shared/components/layout/AppShell.tsx +34 -5
  20. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +30 -1
  21. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
  22. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  23. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  24. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  25. package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
  26. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  27. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  28. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  29. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
  31. package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
  32. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  33. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  34. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  35. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  36. package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
  37. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  38. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  39. package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
  40. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  41. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  42. package/apps/shared/mail/decryptedMessages.ts +11 -3
  43. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  44. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  45. package/apps/shared/mail/reportNotices.ts +3 -1
  46. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  47. package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
  48. package/apps/shared/search/localIndexBuilder.ts +57 -20
  49. package/apps/shared/search/localIndexRpcClient.ts +96 -12
  50. package/apps/shared/search/localIndexSchema.ts +20 -4
  51. package/apps/shared/search/localIndexTransport.ts +6 -2
  52. package/apps/shared/search/localIndexWorker.ts +37 -18
  53. package/apps/shared/search/searchTier2.ts +5 -0
  54. package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
  55. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  56. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  57. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  58. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  59. package/apps/www/_shell.tsx +2 -0
  60. package/apps/www/calendar/index.tsx +225 -61
  61. package/apps/www/contacts/index.tsx +67 -11
  62. package/apps/www/index.tsx +19 -3
  63. package/apps/www/settings/encryption/index.tsx +21 -1
  64. package/apps/www/settings/privacy/index.tsx +89 -39
  65. package/apps/www/tasks/index.tsx +24 -6
  66. package/dist/apps/escrow/matters/[uid].js +2 -2
  67. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  68. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  69. package/dist/apps/shared/calendar/sealedEvents.d.ts +14 -0
  70. package/dist/apps/shared/calendar/sealedEvents.js +85 -0
  71. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  72. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  73. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  74. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +25 -2
  75. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  76. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  77. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  78. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  79. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  80. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  81. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  82. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  83. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  84. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  85. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  86. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  87. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  88. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  89. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  90. package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
  91. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  92. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  93. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  94. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  95. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +27 -2
  96. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
  97. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
  98. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  99. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  100. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  101. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  102. package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
  103. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  104. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  105. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  106. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  107. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  108. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  109. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
  110. package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
  111. package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
  112. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  113. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  114. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  115. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  116. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  117. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  118. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  119. package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
  120. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  121. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  122. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  123. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  124. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
  125. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
  126. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  127. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  128. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  129. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  130. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  131. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  132. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  133. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  134. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  135. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  136. package/dist/apps/shared/mail/reportNotices.js +3 -1
  137. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  138. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  139. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  140. package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
  141. package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
  142. package/dist/apps/shared/search/localIndexBuilder.js +50 -17
  143. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  144. package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
  145. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  146. package/dist/apps/shared/search/localIndexSchema.js +18 -4
  147. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
  148. package/dist/apps/shared/search/localIndexWorker.d.ts +11 -0
  149. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  150. package/dist/apps/shared/search/searchTier2.js +5 -0
  151. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +102 -0
  152. package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
  153. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  154. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  155. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  156. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  157. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  158. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  159. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  160. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  161. package/dist/apps/www/_shell.d.ts +1 -1
  162. package/dist/apps/www/_shell.js +2 -2
  163. package/dist/apps/www/calendar/index.js +88 -20
  164. package/dist/apps/www/contacts/index.js +64 -11
  165. package/dist/apps/www/index.js +19 -3
  166. package/dist/apps/www/settings/encryption/index.js +16 -4
  167. package/dist/apps/www/settings/privacy/index.js +47 -6
  168. package/dist/apps/www/tasks/index.js +21 -7
  169. package/dist/lib/admin/domainsApi.d.ts +1 -1
  170. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  171. package/dist/lib/calendar/calendarApi.d.ts +52 -6
  172. package/dist/lib/calendar/calendarApi.js +110 -17
  173. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  174. package/dist/lib/calendar/calendarMutations.js +73 -14
  175. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  176. package/dist/lib/calendar/inviteApi.js +86 -7
  177. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  178. package/dist/lib/calendar/protectedEvents.js +113 -0
  179. package/dist/lib/calendar/recurrence.d.ts +2 -0
  180. package/dist/lib/calendar/recurrence.js +16 -0
  181. package/dist/lib/calendar/sealedInvites.d.ts +9 -0
  182. package/dist/lib/calendar/sealedInvites.js +65 -0
  183. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  184. package/dist/lib/contacts/contactsApi.js +23 -7
  185. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  186. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  187. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  188. package/dist/lib/crypto/composeSecurity.js +13 -5
  189. package/dist/lib/crypto/keyExport.d.ts +54 -0
  190. package/dist/lib/crypto/keyExport.js +161 -0
  191. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  192. package/dist/lib/crypto/keyPairMatch.js +65 -0
  193. package/dist/lib/crypto/keySession.d.ts +64 -3
  194. package/dist/lib/crypto/keySession.js +179 -12
  195. package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
  196. package/dist/lib/crypto/keyvaultApi.js +45 -3
  197. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  198. package/dist/lib/crypto/meetingKeys.js +87 -0
  199. package/dist/lib/crypto/messageSecurity.d.ts +56 -3
  200. package/dist/lib/crypto/messageSecurity.js +201 -13
  201. package/dist/lib/crypto/mime.d.ts +5 -0
  202. package/dist/lib/crypto/mime.js +25 -0
  203. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  204. package/dist/lib/crypto/openPgpKey.js +89 -0
  205. package/dist/lib/crypto/openpgp.d.ts +47 -0
  206. package/dist/lib/crypto/openpgp.js +88 -0
  207. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  208. package/dist/lib/crypto/pgpMessage.js +271 -0
  209. package/dist/lib/crypto/sharedMailboxKeys.d.ts +62 -0
  210. package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
  211. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  212. package/dist/lib/crypto/smimeMessage.js +11 -7
  213. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  214. package/dist/lib/crypto/zeroAccessKey.js +71 -0
  215. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  216. package/dist/lib/mail/mailApi.d.ts +63 -2
  217. package/dist/lib/mail/mailApi.js +5 -1
  218. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  219. package/dist/lib/mail/mailboxImportApi.js +15 -0
  220. package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
  221. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  222. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  223. package/dist/lib/tasks/tasksApi.js +10 -4
  224. package/dist/lib/util/clientPlatform.d.ts +14 -0
  225. package/dist/lib/util/clientPlatform.js +26 -0
  226. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  227. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  228. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  229. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  230. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  231. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  232. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  233. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  234. package/lib/admin/domainsApi.ts +1 -1
  235. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  236. package/lib/calendar/calendarApi.ts +143 -17
  237. package/lib/calendar/calendarMutations.ts +79 -12
  238. package/lib/calendar/inviteApi.ts +101 -8
  239. package/lib/calendar/protectedEvents.ts +143 -0
  240. package/lib/calendar/recurrence.ts +17 -0
  241. package/lib/calendar/sealedInvites.ts +73 -0
  242. package/lib/contacts/contactsApi.ts +43 -17
  243. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  244. package/lib/crypto/composeSecurity.ts +20 -5
  245. package/lib/crypto/keyExport.ts +229 -0
  246. package/lib/crypto/keyPairMatch.ts +69 -0
  247. package/lib/crypto/keySession.ts +216 -12
  248. package/lib/crypto/keyvaultApi.ts +174 -10
  249. package/lib/crypto/meetingKeys.ts +118 -0
  250. package/lib/crypto/messageSecurity.ts +284 -16
  251. package/lib/crypto/mime.ts +26 -0
  252. package/lib/crypto/openPgpKey.ts +118 -0
  253. package/lib/crypto/openpgp.ts +115 -0
  254. package/lib/crypto/pgpMessage.ts +336 -0
  255. package/lib/crypto/sharedMailboxKeys.ts +402 -0
  256. package/lib/crypto/smimeMessage.ts +11 -7
  257. package/lib/crypto/zeroAccessKey.ts +102 -0
  258. package/lib/mail/conversationsApi.ts +3 -0
  259. package/lib/mail/mailApi.ts +60 -3
  260. package/lib/mail/mailboxImportApi.ts +24 -0
  261. package/lib/search/zeroAccessIndexApi.ts +123 -0
  262. package/lib/tasks/tasksApi.ts +19 -5
  263. package/lib/util/clientPlatform.ts +50 -0
  264. package/lib/videoconf/protectedMeetings.ts +221 -0
  265. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  266. package/lib/zeroAccess/sealedMoves.ts +79 -0
  267. package/lib/zeroAccess/sealedRecords.ts +153 -0
  268. package/package.json +14 -5
@@ -11,6 +11,8 @@ import {
11
11
  createContact,
12
12
  deleteContact,
13
13
  getContact,
14
+ CONTACT_LIST_FIELDS,
15
+ CONTACT_MATCH_FIELDS,
14
16
  listContacts,
15
17
  ensureSuggestedContacts,
16
18
  listContactsInFolders,
@@ -44,6 +46,8 @@ import { useShortcut } from "../../shared/keyboard/useShortcut.js";
44
46
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
45
47
  import { notify } from "../../shared/notifications/store.js";
46
48
  import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
49
+ import { isOpenedRecord, isProtectedRecord, openSealedRecord, openSealedRecords } from "../../../lib/zeroAccess/sealedRecords.js";
50
+ import { useKeysLocked } from "../../shared/zeroAccess/useKeysLocked.js";
47
51
 
48
52
  /** The published model class name of a contact on either database (`ContactMongo`, `ContactSQL`) - not a `ContactList`'s. */
49
53
  const CONTACT_MODEL = /^Contact(Mongo|SQL)$/;
@@ -142,6 +146,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
142
146
  void loadSuggested(suggestedFolderUid);
143
147
  }
144
148
  return listAllPages((page) => listContacts(folderUid, { limit: LIST_PAGE_SIZE, page }, client))
149
+ .then(async (result) => ({ ...result, items: await openSealedRecords(result.items, CONTACT_LIST_FIELDS) }))
145
150
  .then((result) => {
146
151
  setContacts(result.items);
147
152
  setTruncated(result.truncated);
@@ -154,10 +159,25 @@ function ContactsContent({ userUid }: { userUid?: string }) {
154
159
  void reload();
155
160
  }, [folderUid]);
156
161
 
162
+ // The lists hold protected contacts opened with the mailbox's keys: its lock takes them off screen, read again with their placeholders (the
163
+ // contacts, the suggested ones and the Deleted view). They leave the lists first, so a reload that fails can't leave them shown. An open
164
+ // contact's pane and form drop what they opened themselves (`useOpenedRecord`).
165
+ const [lockCount, setLockCount] = useState(0);
166
+ useKeysLocked((locked) => {
167
+ if (locked === mailboxUid) {
168
+ const readable = (list: Contact[]) => list.filter((contact) => !isProtectedRecord(contact));
169
+ setContacts(readable);
170
+ setSuggestedContacts(readable);
171
+ setDeletedContacts(readable);
172
+ void reload();
173
+ setLockCount((count) => count + 1);
174
+ }
175
+ });
176
+
157
177
  /** Reads the Suggested Contacts folder's contacts. A failure leaves the list as it was. */
158
178
  function loadSuggested(suggestedUid: string): Promise<void> {
159
179
  return listAllPages((page) => listContacts(suggestedUid, { limit: LIST_PAGE_SIZE, page }, client))
160
- .then((result) => setSuggestedContacts(result.items))
180
+ .then(async (result) => setSuggestedContacts(await openSealedRecords(result.items, CONTACT_LIST_FIELDS)))
161
181
  .catch(() => undefined);
162
182
  }
163
183
 
@@ -195,7 +215,9 @@ function ContactsContent({ userUid }: { userUid?: string }) {
195
215
  remove(change.uid);
196
216
  return;
197
217
  }
198
- void getContact(change.uid, client).then(
218
+ void getContact(change.uid, client)
219
+ .then((fresh) => openSealedRecord(fresh, CONTACT_LIST_FIELDS))
220
+ .then(
199
221
  (fresh) => {
200
222
  if (fresh.deleted === true) {
201
223
  remove(fresh.uid);
@@ -256,6 +278,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
256
278
  setDeletedLoading(true);
257
279
  setDeletedError(null);
258
280
  listAllPages((page) => listDeletedContacts(folderUid, { limit: LIST_PAGE_SIZE, page }, client))
281
+ .then(async (result) => ({ ...result, items: await openSealedRecords(result.items, CONTACT_LIST_FIELDS) }))
259
282
  .then((result) => {
260
283
  // A server that dropped the `deleted` filter (no delete+update right on this folder) answers with live
261
284
  // contacts, which must never be listed as deleted.
@@ -271,8 +294,8 @@ function ContactsContent({ userUid }: { userUid?: string }) {
271
294
  .catch((err) => setDeletedError(err instanceof ApiRequestError ? err.message : "Could not load deleted contacts."))
272
295
  .finally(() => setDeletedLoading(false));
273
296
  // Re-fetches every time the Deleted view is (re-)selected, matching this page's own `reload()`
274
- // convention elsewhere rather than caching a possibly-stale list across visits.
275
- }, [view, folderUid]);
297
+ // convention elsewhere rather than caching a possibly-stale list across visits - and when the mailbox locks.
298
+ }, [view, folderUid, lockCount]);
276
299
 
277
300
  const viewFiltered = useMemo(() => {
278
301
  switch (view.type) {
@@ -467,9 +490,17 @@ function ContactsContent({ userUid }: { userUid?: string }) {
467
490
  if (!category?.trim()) {
468
491
  return;
469
492
  }
470
- for (const contact of checkedContacts) {
471
- const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
493
+ let locked = 0;
494
+ for (const listed of checkedContacts) {
472
495
  try {
496
+ // A protected one's categories are added to as opened: one that won't open here (the mailbox is locked) shows a placeholder
497
+ // (none), and saving that plus the new one would replace its real categories for good. It is left as it is.
498
+ const contact = await openSealedRecord(listed);
499
+ if (isProtectedRecord(contact) && !isOpenedRecord(contact)) {
500
+ locked++;
501
+ continue;
502
+ }
503
+ const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
473
504
  // Only the changed field - sending the whole fetched contact back includes server-managed
474
505
  // fields restapi rejects, and would overwrite any concurrent edit to the other fields.
475
506
  await updateContact({ uid: contact.uid, version: contact.version, categories }, client);
@@ -477,6 +508,13 @@ function ContactsContent({ userUid }: { userUid?: string }) {
477
508
  notifyApiError(err, "Couldn't update some of the contacts");
478
509
  }
479
510
  }
511
+ if (locked > 0) {
512
+ notify({
513
+ kind: "warning",
514
+ title: "Some contacts weren't categorized",
515
+ message: `${locked === 1 ? "A contact is" : `${locked} contacts are`} protected and can't be changed while your mailbox is locked. Unlock it, then add the category again.`,
516
+ });
517
+ }
480
518
  clearPinnedSignerCache();
481
519
  await reload();
482
520
  });
@@ -484,8 +522,17 @@ function ContactsContent({ userUid }: { userUid?: string }) {
484
522
  // A suggested contact becomes one of the mailbox's own: a copy in its contacts folder, and the suggestion removed (the person is not suggested again).
485
523
  const handleAddToContacts = singleFlight(async () => {
486
524
  // In the All contacts view the selection can hold contacts that already are the mailbox's own: only the suggested ones are added.
487
- for (const contact of checkedContacts.filter((checked) => checked.folderUid === suggestedFolderUid)) {
525
+ let locked = 0;
526
+ for (const listed of checkedContacts.filter((checked) => checked.folderUid === suggestedFolderUid)) {
488
527
  try {
528
+ // Copied in full: the list opened only what it shows.
529
+ const contact = await openSealedRecord(listed);
530
+ // A protected one that won't open here (the mailbox is locked) holds only placeholders: copying them would replace its real
531
+ // name for good once the original is deleted. It is left as it is.
532
+ if (isProtectedRecord(contact) && !isOpenedRecord(contact)) {
533
+ locked++;
534
+ continue;
535
+ }
489
536
  await createContact(
490
537
  {
491
538
  mailboxUid: contact.mailboxUid,
@@ -508,14 +555,23 @@ function ContactsContent({ userUid }: { userUid?: string }) {
508
555
  notifyApiError(err, "Couldn't add some of the contacts");
509
556
  }
510
557
  }
558
+ if (locked > 0) {
559
+ notify({
560
+ kind: "warning",
561
+ title: "Some contacts weren't added",
562
+ message: `${locked === 1 ? "A suggested contact is" : `${locked} suggested contacts are`} protected and can't be opened while your mailbox is locked. Unlock it, then add ${locked === 1 ? "it" : "them"} again.`,
563
+ });
564
+ }
511
565
  setCheckedUids(new Set());
512
566
  await reload();
513
567
  });
514
568
 
515
- function handleExportVCard() {
569
+ async function handleExportVCard() {
570
+ // Exported in full: the list opened only what it shows.
571
+ const contacts = await openSealedRecords(checkedContacts);
516
572
  downloadTextFile(
517
- checkedContacts.length === 1 ? `${checkedContacts[0].displayName}.vcf` : "contacts.vcf",
518
- checkedContacts.length === 1 ? contactToVCard(checkedContacts[0]) : contactsToVCardFile(checkedContacts),
573
+ contacts.length === 1 ? `${contacts[0].displayName}.vcf` : "contacts.vcf",
574
+ contacts.length === 1 ? contactToVCard(contacts[0]) : contactsToVCardFile(contacts),
519
575
  );
520
576
  }
521
577
 
@@ -543,7 +599,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
543
599
  // earlier card's in the file - is left alone. The folder is read in full for that, not just the page of it the list holds.
544
600
  let fresh: ParsedVCardContact[];
545
601
  try {
546
- fresh = withoutDuplicates(parsed, await listContactsInFolders([folderUid], client)).fresh;
602
+ fresh = withoutDuplicates(parsed, await openSealedRecords(await listContactsInFolders([folderUid], client), CONTACT_MATCH_FIELDS)).fresh;
547
603
  } catch (err) {
548
604
  notifyApiError(err, "Couldn't check the file against your contacts");
549
605
  return;
@@ -159,12 +159,28 @@ async function decryptEncryptedRows(messages: Message[], unlocked: UnlockedKeys)
159
159
  encrypted,
160
160
  DECRYPT_CONCURRENCY,
161
161
  async (message): Promise<[string, DecryptedRow] | null> => {
162
+ // A lock while a row was opened has already cleared the shared store: what it opened must not go back in (`decryptForDisplay()`
163
+ // asks the same). The caller's lock generation keeps it off this page.
164
+ const stillUnlocked = (): boolean => getUnlockedKeys(message.mailboxUid) === unlocked;
162
165
  try {
163
- const rawMime = await getMessageRawContent(message.uid);
164
166
  // Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
165
167
  // no encrypted mail in it never needs it.
166
- const { evaluateMessageSecurity } = await (securityModule ??= import("../../lib/crypto/messageSecurity.js"));
167
- const security = await evaluateMessageSecurity(rawMime, unlocked);
168
+ const securityApi = await (securityModule ??= import("../../lib/crypto/messageSecurity.js"));
169
+ // A message sealed at rest carries its subject and preview sealed on the row: no download needed.
170
+ const summary = message.zeroAccess ? await securityApi.openZeroAccessSummary(message.zeroAccess.sealedSummary, unlocked.zeroAccessKeys) : undefined;
171
+ if (summary) {
172
+ const preview = summary.preview.slice(0, LIST_PREVIEW_MAX_LENGTH);
173
+ if (stillUnlocked()) {
174
+ rememberDecrypted(message.uid, message.mailboxUid, { subject: summary.subject, preview });
175
+ }
176
+ return [message.uid, { subject: summary.subject, preview }];
177
+ }
178
+ const rawMime = await getMessageRawContent(message.uid);
179
+ const security = await securityApi.evaluateMessageSecurity(rawMime, unlocked);
180
+ // Nor is a lock a failure to decrypt.
181
+ if (!stillUnlocked()) {
182
+ return null;
183
+ }
168
184
  if (!security.subject && !security.html) {
169
185
  markDecryptFailed(message.uid);
170
186
  return null;
@@ -17,14 +17,17 @@ import {
17
17
  getCurrentSignEnrollment,
18
18
  getEscrowInfo,
19
19
  getKeyVault,
20
+ isOpenPgpKey,
20
21
  rekey,
21
22
  removeMasterKeyWrap,
22
23
  startSignEnrollment,
23
24
  } from "../../../../lib/crypto/keyvaultApi.js";
24
25
  import {
25
26
  ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE,
27
+ OPENPGP_PRIVATE_KEY_AAD_PURPOSE,
26
28
  SIGNING_PRIVATE_KEY_AAD_PURPOSE,
27
29
  UnlockedKeys,
30
+ ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE,
28
31
  destroyUnlockedKeys,
29
32
  getUnlockedKeys,
30
33
  unlockWithPassword,
@@ -51,6 +54,9 @@ import { notifyApiError } from "../../../shared/notifications/apiErrors.js";
51
54
  import Alert from "../../../../lib/components/feedback/Alert.js";
52
55
  import Button from "../../../../lib/components/buttons/Button.js";
53
56
  import Modal from "../../../../lib/components/overlays/Modal.js";
57
+ import ZeroAccessSetting from "../../../shared/components/settings/ZeroAccessSetting.js";
58
+ import KeyExportSetting from "../../../shared/components/settings/KeyExportSetting.js";
59
+ import OpenPgpKeySetting from "../../../shared/components/settings/OpenPgpKeySetting.js";
54
60
  import SigningCertificateCard, { SigningCertificateMode } from "../../../shared/components/settings/SigningCertificateCard.js";
55
61
  import { readStoredSignEnrollment, storeSignEnrollment } from "../../../shared/signing/enrollmentStorage.js";
56
62
  import {
@@ -101,7 +107,14 @@ class StaleSessionKeysError extends Error {
101
107
  const STALE_KEYS_MESSAGE =
102
108
  "Your encryption keys were changed on another device (for example, rotated), so the copy unlocked in this session no longer works. Nothing was changed. Unlock again with your current password, then try again.";
103
109
 
110
+ /** The AAD a vault key is sealed with - by what it is: an OpenPGP secret key, a zero-access key, a signing key or an encryption key. */
104
111
  function wrappedKeyAad(mailboxUid: string, entry: WrappedPrivateKey): Uint8Array {
112
+ if (isOpenPgpKey(entry)) {
113
+ return buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE);
114
+ }
115
+ if (entry.useType === "zero-access") {
116
+ return buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
117
+ }
105
118
  return buildAad(mailboxUid, entry.useType === "sign" ? SIGNING_PRIVATE_KEY_AAD_PURPOSE : ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE);
106
119
  }
107
120
 
@@ -167,6 +180,8 @@ async function rewrapVaultPrivateKeys(
167
180
  algorithm: "AES-256-GCM",
168
181
  fingerprint: entry.fingerprint,
169
182
  useType: entry.useType,
183
+ // An OpenPGP secret key stays one: without its type it would read back as an S/MIME key.
184
+ ...(isOpenPgpKey(entry) ? { type: entry.type } : {}),
170
185
  });
171
186
  }
172
187
  return { mk, wrappedKeys: rewrapped };
@@ -227,7 +242,8 @@ function EncryptionGate({ userUid, impersonating }: { userUid?: string; imperson
227
242
  mailboxKeys={mailbox.keys}
228
243
  canProvision={isOwner}
229
244
  >
230
- <EncryptionContent canManageKeys={isOwner} />
245
+ {/* Mounted anew for each mailbox: switching mailbox keeps the page, and its settings seed their state from the mailbox. */}
246
+ <EncryptionContent key={mailboxUid} canManageKeys={isOwner} />
231
247
  </KeyEnrollmentGate>
232
248
  );
233
249
  }
@@ -975,6 +991,10 @@ function EncryptionContent({ canManageKeys }: { canManageKeys: boolean }) {
975
991
  )}
976
992
  </div>
977
993
 
994
+ <ZeroAccessSetting mailbox={mailbox} canManage={canManageKeys} />
995
+ <KeyExportSetting mailbox={mailbox} canManage={canManageKeys} />
996
+ <OpenPgpKeySetting mailbox={mailbox} canManage={canManageKeys} />
997
+
978
998
  <div>
979
999
  <h2 className="text-sm font-semibold mb-2">Digital signatures</h2>
980
1000
  {activeSigningKey && signingStatus !== "pending" && (
@@ -15,21 +15,25 @@ import {
15
15
  import {
16
16
  MailboxImportFormat,
17
17
  MailboxImportRequest,
18
+ ZERO_ACCESS_IMPORT_MESSAGE,
18
19
  listImportRequests,
20
+ mailIsZeroAccess,
19
21
  uploadMailboxImport,
20
22
  } from "../../../../lib/mail/mailboxImportApi.js";
23
+ import { getEncryptionPolicy } from "../../../../lib/crypto/keyvaultApi.js";
21
24
  import {
22
25
  DataSubjectErasureRequest,
23
26
  createErasureRequest,
24
27
  listErasureRequests,
25
28
  } from "../../../../lib/mail/erasureRequestApi.js";
26
- import { Folder, listFolders } from "../../../../lib/mail/mailApi.js";
29
+ import { Folder, Mailbox, listFolders } from "../../../../lib/mail/mailApi.js";
27
30
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
28
31
  import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
29
32
  import Alert from "../../../../lib/components/feedback/Alert.js";
30
33
  import Button from "../../../../lib/components/buttons/Button.js";
31
34
  import Modal from "../../../../lib/components/overlays/Modal.js";
32
35
  import { notifyApiError } from "../../../shared/notifications/apiErrors.js";
36
+ import { INVALID_REQUEST_CODE } from "../../../../lib/zeroAccess/sealedMoves.js";
33
37
  import GravatarSetting from "../../../shared/components/settings/GravatarSetting.js";
34
38
 
35
39
  const NON_MAIL_FOLDER_TYPES = new Set(["calendar", "contacts", "tasks", "notes"]);
@@ -89,7 +93,7 @@ function PrivacyContent({ userUid }: { userUid?: string }) {
89
93
  {selectedIsOwn ? (
90
94
  <>
91
95
  <ExportSection mailboxUid={mailboxUid} mailboxLabel={`${selected.displayName} (${selected.primarySmtpAddress})`} />
92
- <ImportSection mailboxUid={mailboxUid} />
96
+ <ImportSection mailboxUid={mailboxUid} mailbox={selected} />
93
97
  <ErasureSection mailboxLabel={`${selected.displayName} (${selected.primarySmtpAddress})`} />
94
98
  </>
95
99
  ) : (
@@ -174,7 +178,8 @@ function ExportSection({ mailboxUid, mailboxLabel }: { mailboxUid?: string; mail
174
178
  <p className="text-xs text-text-muted mb-3">
175
179
  Requests a copy of <strong>{mailboxLabel}</strong>&rsquo;s messages, contacts, calendar, tasks, and notes. This runs
176
180
  in the background and can take a few minutes — check back here for a download link once it&rsquo;s
177
- ready.
181
+ ready. Mail stored with zero-access encryption is exported encrypted: open it in a mail app that has your
182
+ exported keys (Settings &rsaquo; Encryption).
178
183
  </p>
179
184
 
180
185
  <form onSubmit={handleCreate} className="flex items-end gap-3 mb-5">
@@ -237,8 +242,36 @@ function ExportSection({ mailboxUid, mailboxLabel }: { mailboxUid?: string; mail
237
242
  );
238
243
  }
239
244
 
240
- function ImportSection({ mailboxUid }: { mailboxUid?: string }) {
245
+ /**
246
+ * Uploads a mail archive for restapi's server-side import. Not offered for a mailbox whose mail is zero-access (`mailIsZeroAccess()`):
247
+ * restapi refuses it (409), since the archive would wait readable in its blob store until the import ran, and the reason is shown
248
+ * instead. The server's zero-access refusal given anyway (zero-access turned on since the page loaded) is shown in its place too; its other
249
+ * 409, an import already in progress, is not.
250
+ */
251
+ function ImportSection({ mailboxUid, mailbox }: { mailboxUid?: string; mailbox: Mailbox }) {
241
252
  const client = useApiClient();
253
+ // Only when the mailbox's own fields say its mail is zero-access is the administrator's policy asked: `undefined` until it answers
254
+ // (counted as allowed), and a failed read counts as allowed too, so the upload is never offered only to be refused.
255
+ const zeroAccessCandidate = mailIsZeroAccess(mailbox, undefined);
256
+ const [allowZeroAccess, setAllowZeroAccess] = useState<boolean | undefined>(undefined);
257
+ const [refused, setRefused] = useState<string | null>(null);
258
+ useEffect(() => {
259
+ if (!zeroAccessCandidate) {
260
+ return;
261
+ }
262
+ let cancelled = false;
263
+ void getEncryptionPolicy(client)
264
+ .then((policy) => {
265
+ if (!cancelled) {
266
+ setAllowZeroAccess(policy.allowZeroAccess);
267
+ }
268
+ })
269
+ .catch(() => undefined);
270
+ return () => {
271
+ cancelled = true;
272
+ };
273
+ }, [zeroAccessCandidate, client]);
274
+ const zeroAccess = mailIsZeroAccess(mailbox, allowZeroAccess);
242
275
  const [folders, setFolders] = useState<Folder[]>([]);
243
276
  const [targetFolderUid, setTargetFolderUid] = useState("");
244
277
  const [foldersError, setFoldersError] = useState<string | null>(null);
@@ -293,7 +326,18 @@ function ImportSection({ mailboxUid }: { mailboxUid?: string }) {
293
326
  setUploading(true);
294
327
  uploadMailboxImport(file, { format: importFormatFromFilename(file.name), targetFolderUid, mailboxUid: mailboxUid! }, client)
295
328
  .then(() => loadRequests())
296
- .catch((err) => notifyApiError(err, "Couldn't upload the file"))
329
+ .catch((err) => {
330
+ if (err instanceof ApiRequestError && err.status === 409 && err.code === INVALID_REQUEST_CODE) {
331
+ // The server refuses an import into this mailbox (its mail is zero-access): say why, in place of the upload.
332
+ setRefused(err.message);
333
+ return;
334
+ }
335
+ // Any other refusal - an import already in progress is a 409 too - leaves the upload offered, and shows that import.
336
+ notifyApiError(err, "Couldn't upload the file");
337
+ if (err instanceof ApiRequestError && err.status === 409) {
338
+ void loadRequests();
339
+ }
340
+ })
297
341
  .finally(() => setUploading(false));
298
342
  }
299
343
 
@@ -305,43 +349,49 @@ function ImportSection({ mailboxUid }: { mailboxUid?: string }) {
305
349
  choose below. This runs in the background — check back here for its progress.
306
350
  </p>
307
351
 
308
- {folders.length > 0 && (
309
- <label className="flex flex-col gap-1.5 text-sm mb-3 max-w-xs">
310
- <span className="font-semibold">Destination folder</span>
311
- <select
312
- aria-label="Destination folder"
313
- className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
314
- value={targetFolderUid}
315
- onChange={(e) => setTargetFolderUid(e.target.value)}
352
+ {zeroAccess || refused ? (
353
+ <Alert>{refused ?? ZERO_ACCESS_IMPORT_MESSAGE}</Alert>
354
+ ) : (
355
+ <>
356
+ {folders.length > 0 && (
357
+ <label className="flex flex-col gap-1.5 text-sm mb-3 max-w-xs">
358
+ <span className="font-semibold">Destination folder</span>
359
+ <select
360
+ aria-label="Destination folder"
361
+ className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
362
+ value={targetFolderUid}
363
+ onChange={(e) => setTargetFolderUid(e.target.value)}
364
+ >
365
+ {folders.map((folder) => (
366
+ <option key={folder.uid} value={folder.uid}>
367
+ {folder.name}
368
+ </option>
369
+ ))}
370
+ </select>
371
+ </label>
372
+ )}
373
+
374
+ <Button
375
+ type="button"
376
+ variant="secondary"
377
+ className="!w-auto mb-3"
378
+ loading={uploading}
379
+ disabled={uploading || !targetFolderUid}
380
+ onClick={() => fileInputRef.current?.click()}
316
381
  >
317
- {folders.map((folder) => (
318
- <option key={folder.uid} value={folder.uid}>
319
- {folder.name}
320
- </option>
321
- ))}
322
- </select>
323
- </label>
382
+ Upload Mbox or PST file
383
+ </Button>
384
+ <input
385
+ ref={fileInputRef}
386
+ type="file"
387
+ accept=".mbox,.pst"
388
+ aria-label="Upload mail archive"
389
+ className="hidden"
390
+ onChange={handleFileChange}
391
+ />
392
+ </>
324
393
  )}
325
394
 
326
- <Button
327
- type="button"
328
- variant="secondary"
329
- className="!w-auto mb-3"
330
- loading={uploading}
331
- disabled={uploading || !targetFolderUid}
332
- onClick={() => fileInputRef.current?.click()}
333
- >
334
- Upload Mbox or PST file
335
- </Button>
336
- <input
337
- ref={fileInputRef}
338
- type="file"
339
- accept=".mbox,.pst"
340
- aria-label="Upload mail archive"
341
- className="hidden"
342
- onChange={handleFileChange}
343
- />
344
-
345
395
  {foldersError && <Alert>{foldersError}</Alert>}
346
396
  {loadError && <Alert>{loadError}</Alert>}
347
397
 
@@ -36,6 +36,9 @@ import { useShortcut } from "../../shared/keyboard/useShortcut.js";
36
36
  import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
37
37
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
38
38
  import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
39
+ import ProtectedMark from "../../shared/zeroAccess/ProtectedMark.js";
40
+ import { useKeysLocked } from "../../shared/zeroAccess/useKeysLocked.js";
41
+ import { isProtectedRecord, openSealedRecord, openSealedRecords } from "../../../lib/zeroAccess/sealedRecords.js";
39
42
 
40
43
  /** The published model class name of a task on either database (`TaskMongo`, `TaskSQL`) - not a `TaskList`'s. */
41
44
  const TASK_MODEL = /^Task(Mongo|SQL)$/;
@@ -132,6 +135,7 @@ function TasksContent() {
132
135
  setLoading(true);
133
136
  setError(null);
134
137
  return listAllPages((page) => listTasks(folderUid, { limit: LIST_PAGE_SIZE, page }, client))
138
+ .then(async (result) => ({ ...result, items: await openSealedRecords(result.items) }))
135
139
  .then((result) => {
136
140
  if (run === loadRun.current) {
137
141
  setTasks(result.items);
@@ -154,6 +158,15 @@ function TasksContent() {
154
158
  void reload();
155
159
  }, [folderUid]);
156
160
 
161
+ // The list holds protected tasks opened with the mailbox's keys: its lock takes them off screen, read again with their placeholders. They
162
+ // leave the list first, so a reload that fails can't leave them shown.
163
+ useKeysLocked((locked) => {
164
+ if (locked === mailboxUid) {
165
+ setTasks((list) => list.filter((task) => !isProtectedRecord(task)));
166
+ void reload();
167
+ }
168
+ });
169
+
157
170
  // A task created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by its
158
171
  // uid and replaces (or joins) the list while it is still in this folder; one that is gone (a delete, a 404, or moved to another folder) leaves it. A
159
172
  // failed fetch changes nothing: the next load shows it as it is.
@@ -167,7 +180,7 @@ function TasksContent() {
167
180
  remove();
168
181
  return;
169
182
  }
170
- void getTask(change.uid, client).then(
183
+ void getTask(change.uid, client).then(openSealedRecord).then(
171
184
  (fresh) => {
172
185
  if (fresh.folderUid !== folderUid) {
173
186
  remove();
@@ -240,7 +253,7 @@ function TasksContent() {
240
253
  setCreateError("That mailbox has no Tasks folder.");
241
254
  return;
242
255
  }
243
- const created = await createTask(
256
+ const created = await openSealedRecord(await createTask(
244
257
  {
245
258
  mailboxUid: target,
246
259
  folderUid: targetFolderUid,
@@ -250,7 +263,7 @@ function TasksContent() {
250
263
  priority,
251
264
  },
252
265
  client,
253
- );
266
+ ));
254
267
  if (target === mailboxUid) {
255
268
  setAddedElsewhere(null);
256
269
  setTasks((prev) => [...prev, created]);
@@ -289,10 +302,12 @@ function TasksContent() {
289
302
  const nextCompleted = !task.completed;
290
303
  setTasks((prev) => prev.map((t) => (t.uid === task.uid ? { ...t, completed: nextCompleted } : t)));
291
304
  try {
292
- const updated = await setTaskCompleted(task, nextCompleted, client);
305
+ const updated = await openSealedRecord(await setTaskCompleted(task, nextCompleted, client));
293
306
  setTasks((prev) => prev.map((t) => (t.uid === task.uid ? updated : t)));
294
307
  } catch (err) {
295
- setTasks((prev) => prev.map((t) => (t.uid === task.uid ? task : t)));
308
+ // Only the flag goes back, on the row there now: `task` is the row as it was shown, opened, and a lock since may have
309
+ // reloaded it with its placeholders (#224), which putting it back whole would undo.
310
+ setTasks((prev) => prev.map((t) => (t.uid === task.uid ? { ...t, completed: task.completed } : t)));
296
311
  notifyApiError(err, "Couldn't update the task");
297
312
  } finally {
298
313
  setTogglePending(task.uid, false);
@@ -660,6 +675,7 @@ function TaskGroup({ label, tasks, checkedUids, onToggleChecked, onToggle, toggl
660
675
  <CompletionToggle task={task} onToggle={onToggle} pending={toggling.has(task.uid)} />
661
676
  <span className={["flex-1 text-sm", task.completed ? "line-through text-text-muted" : ""].join(" ")}>
662
677
  {task.title}
678
+ <ProtectedMark record={task} />
663
679
  </span>
664
680
  {task.priority !== "normal" && (
665
681
  <span className={["text-xs font-medium", PRIORITY_CLASS[task.priority]].join(" ")}>
@@ -742,7 +758,9 @@ function TaskTable({ tasks, checkedUids, onToggleChecked, onToggle, toggling, on
742
758
  <td className="px-3 py-2">
743
759
  <CompletionToggle task={task} onToggle={onToggle} pending={toggling.has(task.uid)} />
744
760
  </td>
745
- <td className={["px-3 py-2", task.completed ? "line-through text-text-muted" : ""].join(" ")}>{task.title}</td>
761
+ <td className={["px-3 py-2", task.completed ? "line-through text-text-muted" : ""].join(" ")}>{task.title}
762
+ <ProtectedMark record={task} />
763
+ </td>
746
764
  <td className="px-3 py-2 text-text-muted">{task.dueDate ? parseDueDate(task.dueDate).toLocaleDateString() : ""}</td>
747
765
  <td className={["px-3 py-2", PRIORITY_CLASS[task.priority]].join(" ")}>{PRIORITY_LABEL[task.priority]}</td>
748
766
  <td className="px-3 py-2">
@@ -182,7 +182,7 @@ function MatterDetailContent({ uid }) {
182
182
  try {
183
183
  const material = await getAccessRequestMaterial(request.uid);
184
184
  if (seq === materialSeq.current)
185
- setMaterialText(JSON.stringify(material.masterKeyWraps, null, 2));
185
+ setMaterialText(JSON.stringify(material, null, 2));
186
186
  }
187
187
  catch (err) {
188
188
  if (seq === materialSeq.current) {
@@ -270,5 +270,5 @@ function MatterDetailContent({ uid }) {
270
270
  ? "bg-surface-alt text-text-muted"
271
271
  : "bg-success text-white",
272
272
  ].join(" "), children: request.status })] }), actionErrors[request.uid] && _jsx(Alert, { children: actionErrors[request.uid] }), _jsxs("div", { className: "flex gap-3 mt-3", children: [!matter.closedAt && request.status === "pending" && (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", className: "!w-auto", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => setApproveTarget(request), children: "Approve" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => setDenyTarget(request), children: "Deny" })] })), !matter.closedAt && (request.status === "approved" || request.status === "fulfilled") && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => handleGetMaterial(request), children: "Get material" }))] })] }, request.uid))) })), _jsx(LoadMoreButton, { list: requests, label: "Load more access requests" })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide", children: "Export this matter" }), !matter.closedAt && (_jsx(Button, { type: "button", className: "!w-auto", loading: creatingExport, disabled: creatingExport, onClick: handleCreateExport, children: "+ New export" }))] }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Exports messages (narrowed to this matter\u2019s own date range), plus full contacts, calendar events, tasks, and notes, from every custodian mailbox actually assigned to this matter\u2019s escrow scope, as one combined NDJSON file." }), exportError && _jsx(Alert, { children: exportError }), exportRequests.loadError && _jsx(Alert, { children: exportRequests.loadError }), matterExportRequests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: exportRequests.loading ? "Loading…" : "No export requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: matterExportRequests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [new Date(request.dateCreated).toLocaleString(), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsxs("span", { className: "flex items-center gap-3", children: [_jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: statusLabel(request.status) }), !matter.closedAt && request.status === "ready" && (_jsx("a", { href: matterExportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-3", children: "Search this matter\u2019s custodians" }), matter.closedAt ? (_jsx("p", { className: "text-sm text-text-muted", children: "This matter is closed, so its custodians can no longer be searched or exported." })) : (_jsxs("form", { onSubmit: handleSearch, className: "flex gap-3 mb-4", children: [_jsx("input", { "aria-label": "Search this matter", className: INPUT_CLASS, value: searchText, onChange: (e) => setSearchText(e.target.value), placeholder: "Free text or an operator filter, e.g. from:alice@example.com" }), _jsx(Button, { type: "submit", className: "!w-auto shrink-0", loading: searching, disabled: searching || !searchText.trim(), children: "Search" })] })), searchError && _jsx(Alert, { children: searchError }), searchResults &&
273
- (Object.keys(searchResults).length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No custodian mailboxes could be searched." })) : (_jsx("div", { className: "flex flex-col gap-4", children: Object.entries(searchResults).map(([mailboxUid, page]) => (_jsxs("div", { children: [_jsx("h3", { className: "text-sm font-semibold mb-2", children: mailboxUid }), page.results.length === 0 ? (_jsx("p", { className: "text-xs text-text-muted", children: "No matches." })) : (_jsx("ul", { className: "flex flex-col gap-1", children: page.results.map((result) => (_jsxs("li", { className: "text-sm py-1 px-2 bg-surface-alt rounded-sm", children: [result.entityType, " \u00B7 ", result.entityUid, result.snippet && _jsxs("span", { className: "text-text-muted", children: [" \u2014 ", result.snippet] })] }, `${result.entityType}-${result.entityUid}`))) }))] }, mailboxUid))) })))] }), _jsxs(Modal, { open: confirmingClose, onClose: closeCloseModal, title: "Close matter", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Close ", _jsx("strong", { children: matter.name }), "? Once closed, no new access requests, exports, searches, or key material reads are allowed under it. This cannot be reopened here."] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Custodians" }), _jsx("dd", { className: "break-all", children: matter.custodianMailboxUids.join(", ") }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) })] }), closeError && _jsx(Alert, { children: closeError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: closing, onClick: closeCloseModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: closing, disabled: closing, onClick: handleClose, children: "Close matter" })] })] }), _jsxs(Modal, { open: approveTarget !== null, onClose: () => setApproveTarget(null), title: "Approve access request", children: [_jsx("p", { className: "text-sm mb-3", children: "Approving lets holders of this matter\u2019s escrow scope read the key material for this mailbox once enough holders have approved." }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Matter" }), _jsx("dd", { children: matter.name }), _jsx("dt", { className: "text-text-muted", children: "Mailbox" }), _jsx("dd", { className: "break-all", children: approveTarget?.mailboxUid }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) }), _jsx("dt", { className: "text-text-muted", children: "Approvals" }), _jsxs("dd", { children: [approveTarget?.approvals.length, " of ", approveTarget?.requiredHoldersAtCreation, " so far, including requester ", approveTarget?.requestedByUserUid] })] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setApproveTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void handleApprove(), children: "Approve request" })] })] }), _jsxs(Modal, { open: denyTarget !== null, onClose: () => setDenyTarget(null), title: "Deny access request", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Denying refuses this request for good: it can\u2019t be approved afterwards, and holders can\u2019t read the key material for mailbox ", _jsx("span", { className: "break-all font-semibold", children: denyTarget?.mailboxUid }), " through it. A new request has to be made to ask again."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setDenyTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: () => void handleDeny(), children: "Deny request" })] })] }), _jsx(Modal, { open: showNewRequest, onClose: () => setShowNewRequest(false), title: "New access request", children: _jsxs("form", { onSubmit: handleCreateRequest, className: "flex flex-col gap-4", children: [newRequestError && _jsx(Alert, { children: newRequestError }), _jsx(FormField, { label: "Mailbox uid", htmlFor: "newRequestMailboxUid", children: _jsx("input", { id: "newRequestMailboxUid", type: "text", className: INPUT_CLASS, value: newRequestMailboxUid, onChange: (e) => setNewRequestMailboxUid(e.target.value) }) }), _jsx("p", { className: "text-xs text-text-muted", children: "Must be one of this matter's own custodian mailboxes, currently assigned to this matter's escrow scope. Your own creation counts as the first approval." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: creatingRequest, onClick: () => setShowNewRequest(false), children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: creatingRequest, disabled: creatingRequest, children: "Create request" })] })] }) }), _jsx(Modal, { open: materialFor !== null, onClose: closeMaterialModal, title: "Escrow key material", children: loadingMaterial ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : materialError ? (_jsx(Alert, { children: materialError })) : (_jsxs(_Fragment, { children: [_jsxs("p", { className: "text-sm text-text-muted mb-3", children: ["Still-encrypted master key wraps for ", _jsx("strong", { children: materialFor?.mailboxUid }), " \u2014 this server cannot decrypt these either. Copy the JSON below into your own offline tooling."] }), _jsx("pre", { className: "text-xs font-mono bg-surface-alt border border-border rounded-sm p-3 overflow-x-auto whitespace-pre-wrap break-all", children: materialText })] })) })] }));
273
+ (Object.keys(searchResults).length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No custodian mailboxes could be searched." })) : (_jsx("div", { className: "flex flex-col gap-4", children: Object.entries(searchResults).map(([mailboxUid, page]) => (_jsxs("div", { children: [_jsx("h3", { className: "text-sm font-semibold mb-2", children: mailboxUid }), page.results.length === 0 ? (_jsx("p", { className: "text-xs text-text-muted", children: "No matches." })) : (_jsx("ul", { className: "flex flex-col gap-1", children: page.results.map((result) => (_jsxs("li", { className: "text-sm py-1 px-2 bg-surface-alt rounded-sm", children: [result.entityType, " \u00B7 ", result.entityUid, result.snippet && _jsxs("span", { className: "text-text-muted", children: [" \u2014 ", result.snippet] })] }, `${result.entityType}-${result.entityUid}`))) }))] }, mailboxUid))) })))] }), _jsxs(Modal, { open: confirmingClose, onClose: closeCloseModal, title: "Close matter", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Close ", _jsx("strong", { children: matter.name }), "? Once closed, no new access requests, exports, searches, or key material reads are allowed under it. This cannot be reopened here."] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Custodians" }), _jsx("dd", { className: "break-all", children: matter.custodianMailboxUids.join(", ") }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) })] }), closeError && _jsx(Alert, { children: closeError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: closing, onClick: closeCloseModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: closing, disabled: closing, onClick: handleClose, children: "Close matter" })] })] }), _jsxs(Modal, { open: approveTarget !== null, onClose: () => setApproveTarget(null), title: "Approve access request", children: [_jsx("p", { className: "text-sm mb-3", children: "Approving lets holders of this matter\u2019s escrow scope read the key material for this mailbox once enough holders have approved." }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Matter" }), _jsx("dd", { children: matter.name }), _jsx("dt", { className: "text-text-muted", children: "Mailbox" }), _jsx("dd", { className: "break-all", children: approveTarget?.mailboxUid }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) }), _jsx("dt", { className: "text-text-muted", children: "Approvals" }), _jsxs("dd", { children: [approveTarget?.approvals.length, " of ", approveTarget?.requiredHoldersAtCreation, " so far, including requester ", approveTarget?.requestedByUserUid] })] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setApproveTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void handleApprove(), children: "Approve request" })] })] }), _jsxs(Modal, { open: denyTarget !== null, onClose: () => setDenyTarget(null), title: "Deny access request", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Denying refuses this request for good: it can\u2019t be approved afterwards, and holders can\u2019t read the key material for mailbox ", _jsx("span", { className: "break-all font-semibold", children: denyTarget?.mailboxUid }), " through it. A new request has to be made to ask again."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setDenyTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: () => void handleDeny(), children: "Deny request" })] })] }), _jsx(Modal, { open: showNewRequest, onClose: () => setShowNewRequest(false), title: "New access request", children: _jsxs("form", { onSubmit: handleCreateRequest, className: "flex flex-col gap-4", children: [newRequestError && _jsx(Alert, { children: newRequestError }), _jsx(FormField, { label: "Mailbox uid", htmlFor: "newRequestMailboxUid", children: _jsx("input", { id: "newRequestMailboxUid", type: "text", className: INPUT_CLASS, value: newRequestMailboxUid, onChange: (e) => setNewRequestMailboxUid(e.target.value) }) }), _jsx("p", { className: "text-xs text-text-muted", children: "Must be one of this matter's own custodian mailboxes, currently assigned to this matter's escrow scope. Your own creation counts as the first approval." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: creatingRequest, onClick: () => setShowNewRequest(false), children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: creatingRequest, disabled: creatingRequest, children: "Create request" })] })] }) }), _jsx(Modal, { open: materialFor !== null, onClose: closeMaterialModal, title: "Escrow key material", children: loadingMaterial ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : materialError ? (_jsx(Alert, { children: materialError })) : (_jsxs(_Fragment, { children: [_jsxs("p", { className: "text-sm text-text-muted mb-3", children: ["Still-encrypted key material for ", _jsx("strong", { children: materialFor?.mailboxUid }), ": the escrow master key wrap, and the private keys that read its mail, sealed under that master key. This server cannot decrypt any of it either. Copy the JSON below into your own offline tooling."] }), _jsx("pre", { className: "text-xs font-mono bg-surface-alt border border-border rounded-sm p-3 overflow-x-auto whitespace-pre-wrap break-all", children: materialText })] })) })] }));
274
274
  }
@@ -12,7 +12,21 @@ export interface CalendarReminderNotice {
12
12
  /** The event's own `location`, verbatim - `undefined`/`null` when the event has none. Not necessarily a URL;
13
13
  * see `joinMeetingUrl()` for when it's shown as a join link. */
14
14
  location?: string | null;
15
+ /** The event is of a calendar zero-access encryption protects, or was made from a zero-access invitation: `title` and `location` are
16
+ * placeholders (`openedReminder()`). */
17
+ protected?: boolean;
18
+ /** Set by `openedReminder()` on a protected notice it opened: the placeholder title the push carried, for anything recorded beyond
19
+ * this tab's memory (the notification history is kept in `sessionStorage` and outlives a mailbox lock). */
20
+ sealedTitle?: string;
21
+ /** Set with `sealedTitle`: the placeholder location the push carried, which `sealedReminder()` puts back. */
22
+ sealedLocation?: string | null;
15
23
  }
24
+ /** `notice` with a protected event's real title and location, read from the event itself (opened with the mailbox's keys); `notice` as it
25
+ * came when the event can't be read or opened - the alarm still goes off, with the placeholder. Never rejects. */
26
+ export declare function openedReminder(notice: CalendarReminderNotice): Promise<CalendarReminderNotice>;
27
+ /** `notice` as the push carried it, when `openedReminder()` opened it - for an alarm still shown, or snoozed, when the mailbox locks: its real
28
+ * title and location go with the keys that opened them. `openedReminder()` opens it again (it is still `protected`). */
29
+ export declare function sealedReminder(notice: CalendarReminderNotice): CalendarReminderNotice;
16
30
  /** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
17
31
  * else (a room name, an address, free-text instructions are all real, common `location` values this must not
18
32
  * treat as a link). Trimmed first, so incidental whitespace never hides a real link. */