@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
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useState } from "react";
6
- import { Contact, setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
6
+ import { Contact, setContactFavorite, updateContact } from "../../../../lib/contacts/contactsApi.js";
7
7
  import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
8
8
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
9
9
  import { notifyApiError } from "../../notifications/apiErrors.js";
@@ -15,6 +15,11 @@ import ContactPhotoEditor from "./ContactPhotoEditor.js";
15
15
  import KeyChangeReview from "./KeyChangeReview.js";
16
16
  import { KEY_CHANGE_STALE_MESSAGE, formatDate, keyPinnedSince, revocationLabel } from "./contactKeys.js";
17
17
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
18
+ import { type EndToEndFormat, isOpenPgpKey, keyFormat, OPENPGP_KEY_TYPE, type PublicKey, trustSigner } from "../../../../lib/crypto/keyvaultApi.js";
19
+ import { ApiRequestError } from "../../../../lib/util/api.js";
20
+ import ProtectedMark from "../../zeroAccess/ProtectedMark.js";
21
+ import { useOpenedRecord } from "../../zeroAccess/useOpenedRecord.js";
22
+ import { isOpenedRecord, isProtectedRecord, openSealedRecord } from "../../../../lib/zeroAccess/sealedRecords.js";
18
23
 
19
24
  /** Groups a hex fingerprint into 4-character blocks (`ab12 cd34 ...`) for out-of-band verification -
20
25
  * the spec's own use case ("compare this over the phone") is materially easier with a grouped string
@@ -56,7 +61,28 @@ export interface ContactDetailPaneProps {
56
61
  * always visible alongside the contact list) and the mobile detail route
57
62
  * (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
58
63
  */
59
- export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite = true, backHref, onKeysChanged, onChanged, canResolveKeys }: ContactDetailPaneProps) {
64
+ /** The pane for `props.contact` with all its protected details opened (a list hands over a contact with only what it shows opened). */
65
+ export default function ContactDetailPane(props: ContactDetailPaneProps) {
66
+ const { record, ready } = useOpenedRecord(props.contact);
67
+ const { onChanged } = props;
68
+ // The server answers a change with the protected details sealed: what goes back to the list is opened again.
69
+ const openedOnChanged = onChanged && ((changed: Contact) => void openSealedRecord(changed).then(onChanged));
70
+ // A protected contact that can't be opened here shows placeholders: an edit would save them over the real details.
71
+ const editLocked = ready && isProtectedRecord(record) && !isOpenedRecord(record);
72
+ return <ContactDetailPaneView {...props} contact={record!} onChanged={openedOnChanged} editLocked={editLocked} />;
73
+ }
74
+
75
+ function ContactDetailPaneView({
76
+ contact,
77
+ onEdit,
78
+ onDelete,
79
+ canWrite = true,
80
+ backHref,
81
+ onKeysChanged,
82
+ onChanged,
83
+ canResolveKeys,
84
+ editLocked,
85
+ }: ContactDetailPaneProps & { editLocked: boolean }) {
60
86
  const client = useApiClient();
61
87
  // The outcome of the last key decision, kept per contact so selecting another contact doesn't carry it over.
62
88
  const [keyNotice, setKeyNotice] = useState<{ contactUid: string; text: string } | null>(null);
@@ -111,6 +137,7 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
111
137
  <div>
112
138
  <h1 className="text-xl font-bold tracking-tight">
113
139
  {contact.displayName}
140
+ <ProtectedMark record={contact} />
114
141
  <FavoriteStarButton favorite={!!contact.favorite} disabled={photoBusy} onToggle={() => void handleToggleFavorite()} className="ml-1 align-middle" />
115
142
  </h1>
116
143
  {contact.jobTitle && contact.company && (
@@ -136,7 +163,14 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
136
163
  </>
137
164
  ) : (
138
165
  <>
139
- <Button type="button" variant="secondary" className="!w-auto" onClick={onEdit}>
166
+ <Button
167
+ type="button"
168
+ variant="secondary"
169
+ className="!w-auto"
170
+ onClick={onEdit}
171
+ disabled={editLocked}
172
+ title={editLocked ? "This contact is protected: unlock your mailbox to edit it" : undefined}
173
+ >
140
174
  Edit
141
175
  </Button>
142
176
  <Button type="button" variant="secondary" className="!w-auto text-danger" onClick={() => setConfirmingDeleteUid(contact.uid)}>
@@ -148,6 +182,12 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
148
182
  )}
149
183
  </div>
150
184
 
185
+ {editLocked && canWrite && (
186
+ <p role="status" className="text-sm text-text-muted">
187
+ This contact is protected and can&rsquo;t be edited while your mailbox is locked. Unlock it to edit.
188
+ </p>
189
+ )}
190
+
151
191
  <div className="bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm">
152
192
  {contact.emails.length > 0 && (
153
193
  <div>
@@ -196,18 +236,26 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
196
236
  previousKeys.length > 0 ? (
197
237
  <div>
198
238
  <div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Encryption</div>
239
+ {isProtectedRecord(contact) && (
240
+ <p className="mb-2 text-xs text-text-muted">
241
+ This contact&rsquo;s details are protected by zero-access encryption. Its email addresses aren&rsquo;t - the server needs
242
+ them to deliver mail and to find this contact&rsquo;s encryption keys.
243
+ </p>
244
+ )}
199
245
  {notice && (
200
246
  <p role="status" className="mb-2 py-2 px-3 rounded-sm bg-surface-alt text-text">
201
247
  {notice}
202
248
  </p>
203
249
  )}
204
250
  {keyConflicts.map((conflict) => {
205
- const pinned = contact.keys?.find((key) => key.useType === conflict.useType);
206
- const kind = conflict.useType === "sign" ? "signing" : "encryption";
251
+ // Each format's key is pinned on its own: an OpenPGP key's conflict is with the pinned OpenPGP key.
252
+ const openPgp = isOpenPgpKey(conflict.observedKey);
253
+ const pinned = contact.keys?.find((key) => key.useType === conflict.useType && keyFormat(key) === keyFormat(conflict.observedKey));
254
+ const kind = openPgp ? "OpenPGP" : conflict.useType === "sign" ? "signing" : "encryption";
207
255
  return (
208
256
  <section
209
- key={conflict.useType}
210
- aria-label={`${conflict.useType === "sign" ? "Signing" : "Encryption"} key change`}
257
+ key={`${conflict.useType}:${keyFormat(conflict.observedKey)}`}
258
+ aria-label={`${openPgp ? "OpenPGP" : conflict.useType === "sign" ? "Signing" : "Encryption"} key change`}
211
259
  className="mb-3 py-3 px-3 rounded-sm bg-warning/15 text-text flex flex-col gap-2"
212
260
  >
213
261
  <h2 className="font-semibold">
@@ -218,6 +266,7 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
218
266
  mailboxUid={contact.mailboxUid}
219
267
  address={contact.emails[0]?.address}
220
268
  useType={conflict.useType}
269
+ {...(openPgp ? { keyType: "openpgp" as const } : {})}
221
270
  ownerName={contact.displayName}
222
271
  current={pinned && { fingerprint: pinned.fingerprint, since: keyPinnedSince(contact, pinned) }}
223
272
  proposed={{
@@ -246,9 +295,12 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
246
295
  : "This contact has not indicated a mutual encryption preference."}
247
296
  </div>
248
297
  )}
298
+ {canWrite && contact.keys?.some((key) => isOpenPgpKey(key)) && (
299
+ <PreferredFormatControl contact={contact} onChanged={onChanged} />
300
+ )}
249
301
  {contact.keys?.map((key) => (
250
302
  <div key={key.fingerprint} className="font-mono text-xs py-0.5">
251
- {key.useType === "sign" ? "Signing" : "Encryption"} key: {formatFingerprint(key.fingerprint)}
303
+ {keyLabel(key)} key: {formatFingerprint(key.fingerprint)}
252
304
  <RevocationBadge label={revocationLabel(key)} />
253
305
  </div>
254
306
  ))}
@@ -259,7 +311,7 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
259
311
  {previousKeys.map((key) => (
260
312
  <li key={`${key.useType}:${key.fingerprint}:${key.replacedAt}`} className="text-xs py-0.5">
261
313
  <span className="font-mono">
262
- {key.useType === "sign" ? "Signing" : "Encryption"} key: {formatFingerprint(key.fingerprint)}
314
+ {keyLabel(key)} key: {formatFingerprint(key.fingerprint)}
263
315
  </span>
264
316
  <RevocationBadge label={revocationLabel(key)} />
265
317
  <span className="text-text-muted">
@@ -275,7 +327,135 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
275
327
  )}
276
328
  </div>
277
329
  ) : null}
330
+ {canWrite && contact.emails[0]?.address && !contact.keys?.some((key) => isOpenPgpKey(key)) && (
331
+ <OpenPgpKeyImport contact={contact} onImported={() => refresh("The OpenPGP key is now trusted for this contact.")} />
332
+ )}
278
333
  </div>
279
334
  </div>
280
335
  );
281
336
  }
337
+
338
+ const FORMAT_NAMES: Record<EndToEndFormat, string> = { smime: "S/MIME", openpgp: "OpenPGP" };
339
+
340
+ /** What "Automatic" picks for `contact` and why (restapi *Choosing the format*, after the user's own choice): what the address
341
+ * advertises, else what it was last seen using, else S/MIME. */
342
+ function automaticFormat(contact: Contact): string {
343
+ const advertised = contact.encryptPreference?.preferFormat;
344
+ if (advertised) {
345
+ return `${FORMAT_NAMES[advertised]}, which they asked for`;
346
+ }
347
+ const observed = contact.observedFormat?.format;
348
+ return observed ? `${FORMAT_NAMES[observed]}, which they used last` : "S/MIME when they have a certificate";
349
+ }
350
+
351
+ /**
352
+ * "Preferred format" for a contact who has an OpenPGP key: which format mail to them is encrypted in when they can receive both.
353
+ * Automatic follows what they advertise or last used; S/MIME or OpenPGP overrides that - e.g. for someone who reads OpenPGP mail
354
+ * but still has an S/MIME certificate from a former employer.
355
+ */
356
+ function PreferredFormatControl({ contact, onChanged }: { contact: Contact; onChanged?: (contact: Contact) => void }) {
357
+ const client = useApiClient();
358
+ const [saving, setSaving] = useState(false);
359
+ async function save(value: string) {
360
+ setSaving(true);
361
+ try {
362
+ const changed = await updateContact(
363
+ { uid: contact.uid, version: contact.version, preferredFormat: value === "smime" || value === "openpgp" ? value : null },
364
+ client,
365
+ );
366
+ onChanged?.(changed);
367
+ } catch (err) {
368
+ notifyApiError(err, "Couldn't update this contact");
369
+ } finally {
370
+ setSaving(false);
371
+ }
372
+ }
373
+ return (
374
+ <label className="flex flex-wrap items-center gap-2 text-sm my-1">
375
+ <span>Preferred format</span>
376
+ <select
377
+ className="border border-border rounded-sm py-1 px-2 bg-surface text-sm"
378
+ value={contact.preferredFormat ?? "auto"}
379
+ disabled={saving}
380
+ onChange={(e) => void save(e.target.value)}
381
+ >
382
+ <option value="auto">Automatic ({automaticFormat(contact)})</option>
383
+ <option value="smime">S/MIME</option>
384
+ <option value="openpgp">OpenPGP</option>
385
+ </select>
386
+ </label>
387
+ );
388
+ }
389
+
390
+ /** What a pinned key is called: an OpenPGP certificate by its format (it signs and encrypts), an S/MIME one by its use. */
391
+ function keyLabel(key: Pick<PublicKey, "useType" | "type">): string {
392
+ return isOpenPgpKey(key) ? "OpenPGP" : key.useType === "sign" ? "Signing" : "Encryption";
393
+ }
394
+
395
+ /**
396
+ * "Add OpenPGP key": pins an OpenPGP public key the user pasted (from the contact's website, a key they sent, a key server they
397
+ * trust) for the contact's first address - `trustSigner()` with `type: "openpgp"`, which restapi checks names that address. For an
398
+ * OpenPGP user whose domain publishes no Web Key Directory, this is how RapidMX can encrypt to them.
399
+ */
400
+ function OpenPgpKeyImport({ contact, onImported }: { contact: Contact; onImported: () => void }) {
401
+ const client = useApiClient();
402
+ const [open, setOpen] = useState(false);
403
+ const [text, setText] = useState("");
404
+ const [busy, setBusy] = useState(false);
405
+ const [error, setError] = useState<string | null>(null);
406
+ if (!open) {
407
+ return (
408
+ <div>
409
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setOpen(true)}>
410
+ Add OpenPGP key&hellip;
411
+ </Button>
412
+ </div>
413
+ );
414
+ }
415
+ async function handleImport(e: React.FormEvent) {
416
+ e.preventDefault();
417
+ setBusy(true);
418
+ setError(null);
419
+ try {
420
+ await trustSigner(contact.mailboxUid, { address: contact.emails[0].address, certificate: text.trim(), type: OPENPGP_KEY_TYPE }, client);
421
+ clearPinnedSignerCache();
422
+ setOpen(false);
423
+ setText("");
424
+ onImported();
425
+ } catch (err) {
426
+ setError(
427
+ err instanceof ApiRequestError && err.status === 400
428
+ ? `That isn't an OpenPGP public key for ${contact.emails[0].address} that can be used now.`
429
+ : err instanceof ApiRequestError && err.status === 409
430
+ ? "A different OpenPGP key is already trusted for this contact."
431
+ : "Couldn't add the key. Try again.",
432
+ );
433
+ } finally {
434
+ setBusy(false);
435
+ }
436
+ }
437
+ return (
438
+ <form onSubmit={(e) => void handleImport(e)} className="flex flex-col gap-2">
439
+ <label className="text-sm font-semibold" htmlFor={`openpgp-key-${contact.uid}`}>
440
+ OpenPGP public key for {contact.emails[0].address}
441
+ </label>
442
+ <textarea
443
+ id={`openpgp-key-${contact.uid}`}
444
+ className="w-full text-xs font-mono border border-border rounded-sm py-1.5 px-2 bg-surface min-h-[8rem]"
445
+ placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"
446
+ value={text}
447
+ onChange={(e) => setText(e.target.value)}
448
+ disabled={busy}
449
+ />
450
+ {error && <Alert>{error}</Alert>}
451
+ <div className="flex gap-2">
452
+ <Button type="submit" className="!w-auto" loading={busy} disabled={busy || !text.trim()}>
453
+ Add key
454
+ </Button>
455
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setOpen(false)} disabled={busy}>
456
+ Cancel
457
+ </Button>
458
+ </div>
459
+ </form>
460
+ );
461
+ }
@@ -27,6 +27,8 @@ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
27
27
  import { useContactPhotoSrc } from "../../../../lib/contacts/useContactPhotoSrc.js";
28
28
  import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
29
29
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
30
+ import { useOpenedRecord } from "../../zeroAccess/useOpenedRecord.js";
31
+ import { isOpenedRecord, isProtectedRecord, openSealedRecord, protectedRecordWrite } from "../../../../lib/zeroAccess/sealedRecords.js";
30
32
 
31
33
  const INPUT_CLASS =
32
34
  "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -52,8 +54,40 @@ const ADDRESS_KINDS: ContactAddressKind[] = ["home", "work", "other"];
52
54
  * mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
53
55
  * desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
54
56
  */
55
- export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel }: ContactFormProps) {
57
+ /** The form for `props.contact` opened in full - its fields are read once, when it mounts, so it waits for a protected contact to open. One
58
+ * that can't be opened here (the mailbox is locked, or this device has no key for it) is never edited: its form would hold placeholders,
59
+ * and saving them would replace - or clear for good - the real details. */
60
+ export default function ContactForm(props: ContactFormProps) {
61
+ const { record, ready } = useOpenedRecord(props.contact);
62
+ // Bumped by the view's "Reload" to fill it again from the contact as it is now.
63
+ const [reloads, setReloads] = useState(0);
64
+ if (!ready) {
65
+ return <p className="text-sm text-text-muted">Opening this contact&hellip;</p>;
66
+ }
67
+ if (isProtectedRecord(record) && !isOpenedRecord(record)) {
68
+ return (
69
+ <div className="flex max-w-xl flex-col items-start gap-3 p-6">
70
+ <p role="status" className="text-sm">
71
+ This contact is protected and can&rsquo;t be edited while your mailbox is locked. Unlock it to edit.
72
+ </p>
73
+ <Button type="button" variant="secondary" className="!w-auto" onClick={props.onCancel}>
74
+ Back
75
+ </Button>
76
+ </div>
77
+ );
78
+ }
79
+ // The server answers a save with the protected details sealed: what goes back is opened again.
80
+ const onSaved = (saved: Contact) => void openSealedRecord(saved).then(props.onSaved);
81
+ // Another contact is another form: its fields are filled from it.
82
+ return <ContactFormView key={`${record?.uid ?? ""}:${reloads}`} {...props} contact={record} onSaved={onSaved} onReload={() => setReloads((n) => n + 1)} />;
83
+ }
84
+
85
+ function ContactFormView({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel, onReload }: ContactFormProps & { onReload: () => void }) {
56
86
  const client = useApiClient();
87
+ // The contact as the form was filled from. A live update hands the open form the contact as changed elsewhere (`contact`), but the fields
88
+ // still hold this one's values: an update is sent at this version, and compared with it, so that change is refused (409) - never
89
+ // overwritten with what the form still shows.
90
+ const [seeded] = useState(contact);
57
91
  const currentPhotoUrl = useContactPhotoSrc(contact, client);
58
92
  const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
59
93
  const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
@@ -77,8 +111,8 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
77
111
  const [error, setError] = useState<string | null>(null);
78
112
  const [saving, setSaving] = useState(false);
79
113
  // The contact as stored, once it has been saved and only the picture is left to do (that is a second request, which can fail on its own):
80
- // a retry must not create the contact again or send the version it started from. `key` is the form's content that was saved.
81
- const [stored, setStored] = useState<{ contact: Contact; key: string } | null>(null);
114
+ // a retry must not create the contact again or send the version it started from. `key` is the form's content that was saved, `input` its fields.
115
+ const [stored, setStored] = useState<{ contact: Contact; key: string; input?: object } | null>(null);
82
116
 
83
117
  function updateEmail(index: number, patch: Partial<ContactEmail>) {
84
118
  setEmails((prev) => prev.map((e, i) => (i === index ? { ...e, ...patch } : e)));
@@ -116,7 +150,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
116
150
  .filter(Boolean);
117
151
  // An update merges only the fields in its body, so a field the user cleared is sent as an
118
152
  // explicit `null` (an omitted/undefined one would keep its stored value); a create just omits it.
119
- const existing = stored?.contact ?? contact;
153
+ const existing = stored?.contact ?? seeded;
120
154
  const cleared = existing ? null : undefined;
121
155
  const input = {
122
156
  displayName: displayName.trim(),
@@ -138,11 +172,14 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
138
172
  // Stored exactly as it is now: only the picture is left.
139
173
  saved = stored.contact;
140
174
  } else if (existing) {
175
+ // A protected contact's details go only when they changed from what the form was shown (or last saved): the server stores
176
+ // them sealed and can't tell an unchanged value from a new one.
177
+ const shown = seeded && (stored?.input ? { ...seeded, ...stored.input } : seeded);
141
178
  saved = await updateContact(
142
179
  {
143
180
  uid: existing.uid,
144
181
  version: existing.version,
145
- ...(input as Omit<ContactPatch, "uid" | "version">),
182
+ ...protectedRecordWrite(shown, input as Omit<ContactPatch, "uid" | "version">, isOpenedRecord(seeded)),
146
183
  },
147
184
  client,
148
185
  );
@@ -164,7 +201,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
164
201
  client,
165
202
  );
166
203
  }
167
- setStored({ contact: saved, key });
204
+ setStored({ contact: saved, key, input });
168
205
  const changePhoto = async (current: Contact) => {
169
206
  if (pickedPhoto) {
170
207
  return uploadContactPhoto(current.uid, current.version, pickedPhoto, client);
@@ -180,7 +217,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
180
217
  }
181
218
  // A version that is stale means an earlier try had been applied after all (its answer never arrived): read the contact as it is and go again, once.
182
219
  const current = await getContact(saved.uid, client);
183
- setStored({ contact: current, key });
220
+ setStored({ contact: current, key, input });
184
221
  saved = await changePhoto(current);
185
222
  }
186
223
  } catch (err) {
@@ -197,6 +234,10 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
197
234
  }
198
235
  }
199
236
 
237
+ // Changed elsewhere since the form was filled (or last saved): a save would be refused.
238
+ const baseline = stored?.contact ?? seeded;
239
+ const changedElsewhere = !!contact && !!baseline && contact.version > baseline.version;
240
+
200
241
  function finish(saved: Contact) {
201
242
  // A message opened later in this page load must see the changed contact's signing keys.
202
243
  clearPinnedSignerCache();
@@ -212,6 +253,14 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
212
253
  <h1 className="text-xl font-bold uppercase tracking-wide mb-3">{contact ? "Edit contact" : "New contact"}</h1>
213
254
 
214
255
  {error && <Alert>{error}</Alert>}
256
+ {changedElsewhere && (
257
+ <div role="status" className="mb-3 flex flex-wrap items-center gap-3 text-sm">
258
+ <span>This contact was changed elsewhere while you were editing it, so saving would be refused. Reload it to edit the changed contact - what you typed here is lost.</span>
259
+ <Button type="button" variant="secondary" className="!w-auto" disabled={saving} onClick={onReload}>
260
+ Reload
261
+ </Button>
262
+ </div>
263
+ )}
215
264
 
216
265
  {!contact && mailboxes && mailboxes.length > 1 && (
217
266
  <FormField label="Mailbox" htmlFor="contact-mailbox">
@@ -16,6 +16,8 @@ export interface KeyChangeReviewProps {
16
16
  /** The contact's address `resolveKeyConflict()` looks the contact up by; absent hides the actions. */
17
17
  address?: string;
18
18
  useType: "sign" | "encrypt";
19
+ /** `"openpgp"` for an OpenPGP certificate (pinned as `useType: "encrypt"`; it signs too); omitted for an S/MIME certificate. */
20
+ keyType?: "openpgp";
19
21
  /** Who the key belongs to, for the advice ("confirm with <name>"). */
20
22
  ownerName: string;
21
23
  /** The currently pinned key and when it was first trusted; absent (couldn't be loaded) hides the actions. */
@@ -44,6 +46,7 @@ export default function KeyChangeReview({
44
46
  mailboxUid,
45
47
  address,
46
48
  useType,
49
+ keyType,
47
50
  ownerName,
48
51
  current,
49
52
  proposed,
@@ -57,7 +60,7 @@ export default function KeyChangeReview({
57
60
  const [busy, setBusy] = useState<"accept" | "reject" | null>(null);
58
61
  const [error, setError] = useState<string | null>(null);
59
62
  const [forbidden, setForbidden] = useState(false);
60
- const kind = useType === "sign" ? "signing" : "encryption";
63
+ const kind = keyType === "openpgp" ? "OpenPGP" : useType === "sign" ? "signing" : "encryption";
61
64
  const actionable = !!address && !!current && canResolve !== false && !forbidden;
62
65
 
63
66
  async function resolve(action: "accept" | "reject") {
@@ -70,6 +73,7 @@ export default function KeyChangeReview({
70
73
  action,
71
74
  expectedPinnedFingerprint: current!.fingerprint,
72
75
  ...(action === "accept" && certificate !== undefined ? { certificate } : {}),
76
+ ...(keyType ? { type: keyType } : {}),
73
77
  });
74
78
  setConfirmOpen(false);
75
79
  onResolved(action);
@@ -9,8 +9,9 @@
9
9
  */
10
10
  import { addDays } from "date-fns";
11
11
  import { CalendarEvent, listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
12
+ import { openSealedEvents } from "../../calendar/sealedEvents.js";
12
13
  import { type CalendarOccurrence, expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
13
- import { type Contact, createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
14
+ import { type Contact, CONTACT_MATCH_FIELDS, createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
14
15
  import { type ParsedVCardContact, parseVCards } from "../../../../lib/contacts/vcard.js";
15
16
  import {
16
17
  type Attachment,
@@ -36,6 +37,7 @@ import {
36
37
  ownContactCard,
37
38
  withoutDuplicates,
38
39
  } from "./participantDetails.js";
40
+ import { openSealedRecord, openSealedRecords } from "../../../../lib/zeroAccess/sealedRecords.js";
39
41
 
40
42
  /** What the card is opened for besides who: the message the participant was met in, and that message's attachments when the caller already has them. */
41
43
  export interface ContactCardContext {
@@ -68,7 +70,9 @@ export async function loadFolders(mailboxes: readonly Mailbox[], client?: ApiCli
68
70
  /** The contact the address belongs to in `folders`' contacts folders, if any. */
69
71
  export async function findContact(folders: readonly Folder[], address: string, client?: ApiClient): Promise<Contact | undefined> {
70
72
  const uids = folders.filter((folder) => folder.type === "contacts").map((folder) => folder.uid);
71
- return findContactByAddress(await listContactsInFolders(uids, client), address);
73
+ const found = findContactByAddress(await listContactsInFolders(uids, client), address);
74
+ // The card shows the one contact in full.
75
+ return found && (await openSealedRecord(found));
72
76
  }
73
77
 
74
78
  /** The last `CARD_LIST_SIZE` messages to or from `address` in `mailboxes`, newest first. */
@@ -90,7 +94,7 @@ export async function loadRecentMessages(mailboxes: readonly Mailbox[], address:
90
94
  export async function loadUpcomingEvents(folders: readonly Folder[], address: string, now: Date, client?: ApiClient): Promise<CalendarOccurrence[]> {
91
95
  const wanted = normalizeAddress(address);
92
96
  const calendars = folders.filter((folder) => folder.type === "calendar");
93
- const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client)));
97
+ const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client).then(openSealedEvents)));
94
98
  const involved = lists
95
99
  .flat()
96
100
  .filter(
@@ -198,7 +202,7 @@ export async function addCardsToAddressBook(
198
202
  scope: { mailboxUid: string; folderUid: string },
199
203
  client?: ApiClient,
200
204
  ): Promise<AddCardsResult> {
201
- const { fresh, skipped } = withoutDuplicates(cards, await listContactsInFolders([scope.folderUid], client));
205
+ const { fresh, skipped } = withoutDuplicates(cards, await openSealedRecords(await listContactsInFolders([scope.folderUid], client), CONTACT_MATCH_FIELDS));
202
206
  let added = 0;
203
207
  for (const card of fresh) {
204
208
  try {
@@ -6,7 +6,7 @@
6
6
  * `MessageDetailPane`). */
7
7
  import { ApiRequestError } from "../../../../lib/util/api.js";
8
8
  import type { Contact } from "../../../../lib/contacts/contactsApi.js";
9
- import { PinnedKeyChangedError, type KeyConflict, type PublicKey } from "../../../../lib/crypto/keyvaultApi.js";
9
+ import { keyFormat, PinnedKeyChangedError, type KeyConflict, type PublicKey } from "../../../../lib/crypto/keyvaultApi.js";
10
10
 
11
11
  export const KEY_CHANGE_STALE_MESSAGE = "This contact's keys changed while you were looking; reload to see the latest.";
12
12
  export const KEY_CHANGE_INVALID_MESSAGE =
@@ -64,9 +64,9 @@ export function conflictSourceLabel(source: KeyConflict["source"]): string {
64
64
  type ContactWithFirstSeen = Pick<Contact, "previousKeys"> & { keysFirstSeen?: number };
65
65
 
66
66
  /** When `key` (one of `contact`'s pinned keys) started being trusted: when it replaced the newest previous key of its
67
- * use, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
67
+ * use and format, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
68
68
  export function keyPinnedSince(contact: ContactWithFirstSeen, key: PublicKey): number {
69
- const replaced = (contact.previousKeys ?? []).filter((previous) => previous.useType === key.useType).map((previous) => previous.replacedAt);
69
+ const replaced = (contact.previousKeys ?? []).filter((previous) => previous.useType === key.useType && keyFormat(previous) === keyFormat(key)).map((previous) => previous.replacedAt);
70
70
  if (replaced.length > 0) {
71
71
  return Math.max(...replaced);
72
72
  }
@@ -116,6 +116,24 @@ export interface AppShellProps extends PluginNavProps {
116
116
  branding?: Branding;
117
117
  /** The signed-in user's stored appearance preferences as the server rendered the page (its `appearance` prop), so the first paint already wears the theme and background - see `AppearanceProvider`. */
118
118
  appearance?: unknown;
119
+ /** A host that signs its account out itself - see `ShellSignOutHost`. Absent (the webmail), "Sign out" ends the one session this
120
+ * origin holds. */
121
+ signOutHost?: ShellSignOutHost;
122
+ }
123
+
124
+ /**
125
+ * How a host that keeps several accounts in one page (`tauri-client`: one webview, one key session, one local-index storage for all of
126
+ * them) signs out the account whose shell is mounted. The shell's own sign-out assumes one user per origin: it destroys every
127
+ * mailbox's unlocked keys and every local index on the device, posts auth-server's cookie logout and navigates to auth-server. With a
128
+ * host, "Sign out" still marks compose as signing out, clears this session's caches and notifications and saves open compose windows,
129
+ * but leaves the keys, the indexes, the logout and where to go next to the host.
130
+ */
131
+ export interface ShellSignOutHost {
132
+ /** Called first, where the shell would destroy every mailbox's unlocked keys: destroys the signing-out account's. */
133
+ destroyKeys(): void;
134
+ /** Called once open compose windows are saved: destroys the account's local indexes, ends its session and leaves the page. Should
135
+ * not reject; a rejection is logged. */
136
+ finish(): Promise<void>;
119
137
  }
120
138
 
121
139
  export interface AppDef {
@@ -181,6 +199,7 @@ export function AppChrome({
181
199
  pluginNav,
182
200
  busy,
183
201
  hideChrome,
202
+ signOutHost,
184
203
  children,
185
204
  }: PropsWithChildren<AppChromeProps>) {
186
205
  const client = useApiClient();
@@ -253,9 +272,10 @@ export function AppChrome({
253
272
  // own, so this is what actually removes the indexes after a console sign-out (the console also records a
254
273
  // pending deletion, retried on the next mail load, in case no mail tab is open). `destroyAllLocalIndexes()`
255
274
  // announces the sign-out on this same channel, which this tab then hears itself, so the ref is set first:
256
- // each tab reacts once, and the tab that started the sign-out ignores its own announcement.
275
+ // each tab reacts once, and the tab that started the sign-out ignores its own announcement. A host that signs out itself
276
+ // (`signOutHost`) keeps other accounts in this origin, so another tab's sign-out isn't this one's.
257
277
  useEffect(() => {
258
- if (!userUid || typeof BroadcastChannel === "undefined") {
278
+ if (!userUid || signOutHost || typeof BroadcastChannel === "undefined") {
259
279
  return;
260
280
  }
261
281
  const channel = new BroadcastChannel(SIGN_OUT_CHANNEL);
@@ -278,15 +298,19 @@ export function AppChrome({
278
298
  });
279
299
  });
280
300
  return () => channel.close();
281
- }, [userUid, authServerUrl]);
301
+ }, [userUid, authServerUrl, signOutHost]);
282
302
 
283
303
  async function handleSignOut() {
284
304
  signingOutRef.current = true;
285
305
  // Before flushing and navigating: compose windows then skip their "Leave site?" prompt, which could
286
306
  // otherwise cancel the sign-out's own navigation.
287
307
  markSigningOut();
288
- // Unlocked private keys never outlive an explicit sign-out.
289
- destroyUnlockedKeys();
308
+ // Unlocked private keys never outlive an explicit sign-out - only the signing-out account's where a host keeps others.
309
+ if (signOutHost) {
310
+ signOutHost.destroyKeys();
311
+ } else {
312
+ destroyUnlockedKeys();
313
+ }
290
314
  // Trusted signer pins read from contacts don't outlive the session either.
291
315
  clearPinnedSignerCache();
292
316
  clearAppearanceCache();
@@ -295,6 +319,11 @@ export function AppChrome({
295
319
  // Open compose windows save edits still waiting on their autosave debounce while the session is still
296
320
  // valid - logout invalidates it. Bounded the same way as logout itself, and never rejects.
297
321
  await flushComposeDrafts(LOGOUT_TIMEOUT_MS);
322
+ if (signOutHost) {
323
+ // The host destroys its account's indexes (spec §11) and ends its session; this page can't tell its accounts apart.
324
+ await signOutHost.finish().catch((err: unknown) => console.warn("The host's sign-out failed:", err));
325
+ return;
326
+ }
298
327
  // The Tier 2 local index MUST be destroyed on explicit logout, the same as unlocked keys themselves
299
328
  // (spec §11). Destroys every index on this device (not only mailboxes opened this page load) and is
300
329
  // awaited before navigating - a navigation tears down the Worker mid-delete otherwise.