@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
@@ -17,4 +17,7 @@ export interface ContactFormProps {
17
17
  * mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
18
18
  * desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
19
19
  */
20
- export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel }: ContactFormProps): React.JSX.Element;
20
+ /** 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
21
+ * 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,
22
+ * and saving them would replace - or clear for good - the real details. */
23
+ export default function ContactForm(props: ContactFormProps): React.JSX.Element;
@@ -15,6 +15,8 @@ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
15
15
  import { useContactPhotoSrc } from "../../../../lib/contacts/useContactPhotoSrc.js";
16
16
  import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
17
17
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
18
+ import { useOpenedRecord } from "../../zeroAccess/useOpenedRecord.js";
19
+ import { isOpenedRecord, isProtectedRecord, openSealedRecord, protectedRecordWrite } from "../../../../lib/zeroAccess/sealedRecords.js";
18
20
  const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
19
21
  const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
20
22
  const ADDRESS_KINDS = ["home", "work", "other"];
@@ -23,8 +25,30 @@ const ADDRESS_KINDS = ["home", "work", "other"];
23
25
  * mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
24
26
  * desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
25
27
  */
26
- export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel }) {
28
+ /** 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
29
+ * 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,
30
+ * and saving them would replace - or clear for good - the real details. */
31
+ export default function ContactForm(props) {
32
+ const { record, ready } = useOpenedRecord(props.contact);
33
+ // Bumped by the view's "Reload" to fill it again from the contact as it is now.
34
+ const [reloads, setReloads] = useState(0);
35
+ if (!ready) {
36
+ return _jsx("p", { className: "text-sm text-text-muted", children: "Opening this contact\u2026" });
37
+ }
38
+ if (isProtectedRecord(record) && !isOpenedRecord(record)) {
39
+ return (_jsxs("div", { className: "flex max-w-xl flex-col items-start gap-3 p-6", children: [_jsx("p", { role: "status", className: "text-sm", children: "This contact is protected and can\u2019t be edited while your mailbox is locked. Unlock it to edit." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: props.onCancel, children: "Back" })] }));
40
+ }
41
+ // The server answers a save with the protected details sealed: what goes back is opened again.
42
+ const onSaved = (saved) => void openSealedRecord(saved).then(props.onSaved);
43
+ // Another contact is another form: its fields are filled from it.
44
+ return _jsx(ContactFormView, { ...props, contact: record, onSaved: onSaved, onReload: () => setReloads((n) => n + 1) }, `${record?.uid ?? ""}:${reloads}`);
45
+ }
46
+ function ContactFormView({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel, onReload }) {
27
47
  const client = useApiClient();
48
+ // The contact as the form was filled from. A live update hands the open form the contact as changed elsewhere (`contact`), but the fields
49
+ // still hold this one's values: an update is sent at this version, and compared with it, so that change is refused (409) - never
50
+ // overwritten with what the form still shows.
51
+ const [seeded] = useState(contact);
28
52
  const currentPhotoUrl = useContactPhotoSrc(contact, client);
29
53
  const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
30
54
  const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
@@ -48,7 +72,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
48
72
  const [error, setError] = useState(null);
49
73
  const [saving, setSaving] = useState(false);
50
74
  // 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):
51
- // a retry must not create the contact again or send the version it started from. `key` is the form's content that was saved.
75
+ // 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.
52
76
  const [stored, setStored] = useState(null);
53
77
  function updateEmail(index, patch) {
54
78
  setEmails((prev) => prev.map((e, i) => (i === index ? { ...e, ...patch } : e)));
@@ -83,7 +107,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
83
107
  .filter(Boolean);
84
108
  // An update merges only the fields in its body, so a field the user cleared is sent as an
85
109
  // explicit `null` (an omitted/undefined one would keep its stored value); a create just omits it.
86
- const existing = stored?.contact ?? contact;
110
+ const existing = stored?.contact ?? seeded;
87
111
  const cleared = existing ? null : undefined;
88
112
  const input = {
89
113
  displayName: displayName.trim(),
@@ -106,10 +130,13 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
106
130
  saved = stored.contact;
107
131
  }
108
132
  else if (existing) {
133
+ // A protected contact's details go only when they changed from what the form was shown (or last saved): the server stores
134
+ // them sealed and can't tell an unchanged value from a new one.
135
+ const shown = seeded && (stored?.input ? { ...seeded, ...stored.input } : seeded);
109
136
  saved = await updateContact({
110
137
  uid: existing.uid,
111
138
  version: existing.version,
112
- ...input,
139
+ ...protectedRecordWrite(shown, input, isOpenedRecord(seeded)),
113
140
  }, client);
114
141
  }
115
142
  else {
@@ -126,7 +153,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
126
153
  ...input,
127
154
  }, client);
128
155
  }
129
- setStored({ contact: saved, key });
156
+ setStored({ contact: saved, key, input });
130
157
  const changePhoto = async (current) => {
131
158
  if (pickedPhoto) {
132
159
  return uploadContactPhoto(current.uid, current.version, pickedPhoto, client);
@@ -143,7 +170,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
143
170
  }
144
171
  // 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.
145
172
  const current = await getContact(saved.uid, client);
146
- setStored({ contact: current, key });
173
+ setStored({ contact: current, key, input });
147
174
  saved = await changePhoto(current);
148
175
  }
149
176
  }
@@ -162,12 +189,15 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
162
189
  setSaving(false);
163
190
  }
164
191
  }
192
+ // Changed elsewhere since the form was filled (or last saved): a save would be refused.
193
+ const baseline = stored?.contact ?? seeded;
194
+ const changedElsewhere = !!contact && !!baseline && contact.version > baseline.version;
165
195
  function finish(saved) {
166
196
  // A message opened later in this page load must see the changed contact's signing keys.
167
197
  clearPinnedSignerCache();
168
198
  onSaved(saved);
169
199
  }
170
- return (_jsxs("form", { onSubmit: handleSubmit, className: "@container flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsxs("div", { className: "flex max-w-xl flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(ContactPhotoField, { displayName: displayName, email: emails[0]?.address, currentUrl: currentPhotoUrl, picked: pickedPhoto, removed: removePhoto, onPick: (file) => {
200
+ return (_jsxs("form", { onSubmit: handleSubmit, className: "@container flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsxs("div", { className: "flex max-w-xl flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), changedElsewhere && (_jsxs("div", { role: "status", className: "mb-3 flex flex-wrap items-center gap-3 text-sm", children: [_jsx("span", { children: "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." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: saving, onClick: onReload, children: "Reload" })] })), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(ContactPhotoField, { displayName: displayName, email: emails[0]?.address, currentUrl: currentPhotoUrl, picked: pickedPhoto, removed: removePhoto, onPick: (file) => {
171
201
  setPickedPhoto(file);
172
202
  setRemovePhoto(false);
173
203
  setError(null);
@@ -6,6 +6,8 @@ export interface KeyChangeReviewProps {
6
6
  /** The contact's address `resolveKeyConflict()` looks the contact up by; absent hides the actions. */
7
7
  address?: string;
8
8
  useType: "sign" | "encrypt";
9
+ /** `"openpgp"` for an OpenPGP certificate (pinned as `useType: "encrypt"`; it signs too); omitted for an S/MIME certificate. */
10
+ keyType?: "openpgp";
9
11
  /** Who the key belongs to, for the advice ("confirm with <name>"). */
10
12
  ownerName: string;
11
13
  /** The currently pinned key and when it was first trusted; absent (couldn't be loaded) hides the actions. */
@@ -37,4 +39,4 @@ export interface KeyChangeReviewProps {
37
39
  * key, calling `resolveKeyConflict()` against the pinned fingerprint shown. Shared by a message's "signing key changed"
38
40
  * notice and a contact's recorded key conflicts.
39
41
  */
40
- export default function KeyChangeReview({ mailboxUid, address, useType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }: KeyChangeReviewProps): React.JSX.Element;
42
+ export default function KeyChangeReview({ mailboxUid, address, useType, keyType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }: KeyChangeReviewProps): React.JSX.Element;
@@ -15,12 +15,12 @@ import { conflictSourceLabel, formatDate, groupFingerprint, keyChangeErrorMessag
15
15
  * key, calling `resolveKeyConflict()` against the pinned fingerprint shown. Shared by a message's "signing key changed"
16
16
  * notice and a contact's recorded key conflicts.
17
17
  */
18
- export default function KeyChangeReview({ mailboxUid, address, useType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }) {
18
+ export default function KeyChangeReview({ mailboxUid, address, useType, keyType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }) {
19
19
  const [confirmOpen, setConfirmOpen] = useState(false);
20
20
  const [busy, setBusy] = useState(null);
21
21
  const [error, setError] = useState(null);
22
22
  const [forbidden, setForbidden] = useState(false);
23
- const kind = useType === "sign" ? "signing" : "encryption";
23
+ const kind = keyType === "openpgp" ? "OpenPGP" : useType === "sign" ? "signing" : "encryption";
24
24
  const actionable = !!address && !!current && canResolve !== false && !forbidden;
25
25
  async function resolve(action) {
26
26
  setBusy(action);
@@ -32,6 +32,7 @@ export default function KeyChangeReview({ mailboxUid, address, useType, ownerNam
32
32
  action,
33
33
  expectedPinnedFingerprint: current.fingerprint,
34
34
  ...(action === "accept" && certificate !== undefined ? { certificate } : {}),
35
+ ...(keyType ? { type: keyType } : {}),
35
36
  });
36
37
  setConfirmOpen(false);
37
38
  onResolved(action);
@@ -9,14 +9,16 @@
9
9
  */
10
10
  import { addDays } from "date-fns";
11
11
  import { listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
12
+ import { openSealedEvents } from "../../calendar/sealedEvents.js";
12
13
  import { expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
13
- import { createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
14
+ import { CONTACT_MATCH_FIELDS, createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
14
15
  import { parseVCards } from "../../../../lib/contacts/vcard.js";
15
16
  import { attachmentContentUrl, getMessage, listAttachments, listFolders, } from "../../../../lib/mail/mailApi.js";
16
17
  import { search } from "../../../../lib/search/searchApi.js";
17
18
  import { ApiRequestError } from "../../../../lib/util/api.js";
18
19
  import { ownMailboxes, primaryMailbox } from "../../mail/primaryMailbox.js";
19
20
  import { cardForAddress, contactInputFor, contactInputFromCard, findContactByAddress, isVCardAttachment, normalizeAddress, ownContactCard, withoutDuplicates, } from "./participantDetails.js";
21
+ import { openSealedRecord, openSealedRecords } from "../../../../lib/zeroAccess/sealedRecords.js";
20
22
  /** How many recent messages and upcoming events the card lists. */
21
23
  export const CARD_LIST_SIZE = 5;
22
24
  /** How far ahead "upcoming" looks, in days. */
@@ -39,7 +41,9 @@ export async function loadFolders(mailboxes, client) {
39
41
  /** The contact the address belongs to in `folders`' contacts folders, if any. */
40
42
  export async function findContact(folders, address, client) {
41
43
  const uids = folders.filter((folder) => folder.type === "contacts").map((folder) => folder.uid);
42
- return findContactByAddress(await listContactsInFolders(uids, client), address);
44
+ const found = findContactByAddress(await listContactsInFolders(uids, client), address);
45
+ // The card shows the one contact in full.
46
+ return found && (await openSealedRecord(found));
43
47
  }
44
48
  /** The last `CARD_LIST_SIZE` messages to or from `address` in `mailboxes`, newest first. */
45
49
  export async function loadRecentMessages(mailboxes, address, client) {
@@ -53,7 +57,7 @@ export async function loadRecentMessages(mailboxes, address, client) {
53
57
  export async function loadUpcomingEvents(folders, address, now, client) {
54
58
  const wanted = normalizeAddress(address);
55
59
  const calendars = folders.filter((folder) => folder.type === "calendar");
56
- const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client)));
60
+ const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client).then(openSealedEvents)));
57
61
  const involved = lists
58
62
  .flat()
59
63
  .filter((event) => event.status !== "cancelled" &&
@@ -138,7 +142,7 @@ export async function buildOwnCardFile(mailbox, mailboxes, userUid, client) {
138
142
  * card's in the same file). A card that cannot be stored fails the whole call after the earlier ones were added - `AddCardsError` says how many.
139
143
  */
140
144
  export async function addCardsToAddressBook(cards, scope, client) {
141
- const { fresh, skipped } = withoutDuplicates(cards, await listContactsInFolders([scope.folderUid], client));
145
+ const { fresh, skipped } = withoutDuplicates(cards, await openSealedRecords(await listContactsInFolders([scope.folderUid], client), CONTACT_MATCH_FIELDS));
142
146
  let added = 0;
143
147
  for (const card of fresh) {
144
148
  try {
@@ -23,7 +23,7 @@ type ContactWithFirstSeen = Pick<Contact, "previousKeys"> & {
23
23
  keysFirstSeen?: number;
24
24
  };
25
25
  /** When `key` (one of `contact`'s pinned keys) started being trusted: when it replaced the newest previous key of its
26
- * use, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
26
+ * use and format, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
27
27
  export declare function keyPinnedSince(contact: ContactWithFirstSeen, key: PublicKey): number;
28
28
  export declare function formatDate(ms: number): string;
29
29
  export {};
@@ -5,7 +5,7 @@
5
5
  /** Display and error helpers for a contact's pinned keys and key changes (`KeyChangeReview`, `ContactDetailPane`,
6
6
  * `MessageDetailPane`). */
7
7
  import { ApiRequestError } from "../../../../lib/util/api.js";
8
- import { PinnedKeyChangedError } from "../../../../lib/crypto/keyvaultApi.js";
8
+ import { keyFormat, PinnedKeyChangedError } from "../../../../lib/crypto/keyvaultApi.js";
9
9
  export const KEY_CHANGE_STALE_MESSAGE = "This contact's keys changed while you were looking; reload to see the latest.";
10
10
  export const KEY_CHANGE_INVALID_MESSAGE = "The new key isn't a valid, current certificate for this address, so it can't be accepted. Nothing was changed.";
11
11
  export const KEY_CHANGE_FORBIDDEN_MESSAGE = "You don't have permission to change this contact's keys.";
@@ -51,9 +51,9 @@ export function conflictSourceLabel(source) {
51
51
  return source === "header" ? "from an incoming message" : "by key discovery";
52
52
  }
53
53
  /** When `key` (one of `contact`'s pinned keys) started being trusted: when it replaced the newest previous key of its
54
- * use, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
54
+ * use and format, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
55
55
  export function keyPinnedSince(contact, key) {
56
- const replaced = (contact.previousKeys ?? []).filter((previous) => previous.useType === key.useType).map((previous) => previous.replacedAt);
56
+ const replaced = (contact.previousKeys ?? []).filter((previous) => previous.useType === key.useType && keyFormat(previous) === keyFormat(key)).map((previous) => previous.replacedAt);
57
57
  if (replaced.length > 0) {
58
58
  return Math.max(...replaced);
59
59
  }
@@ -40,6 +40,23 @@ export interface AppShellProps extends PluginNavProps {
40
40
  branding?: Branding;
41
41
  /** 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`. */
42
42
  appearance?: unknown;
43
+ /** A host that signs its account out itself - see `ShellSignOutHost`. Absent (the webmail), "Sign out" ends the one session this
44
+ * origin holds. */
45
+ signOutHost?: ShellSignOutHost;
46
+ }
47
+ /**
48
+ * How a host that keeps several accounts in one page (`tauri-client`: one webview, one key session, one local-index storage for all of
49
+ * them) signs out the account whose shell is mounted. The shell's own sign-out assumes one user per origin: it destroys every
50
+ * mailbox's unlocked keys and every local index on the device, posts auth-server's cookie logout and navigates to auth-server. With a
51
+ * host, "Sign out" still marks compose as signing out, clears this session's caches and notifications and saves open compose windows,
52
+ * but leaves the keys, the indexes, the logout and where to go next to the host.
53
+ */
54
+ export interface ShellSignOutHost {
55
+ /** Called first, where the shell would destroy every mailbox's unlocked keys: destroys the signing-out account's. */
56
+ destroyKeys(): void;
57
+ /** Called once open compose windows are saved: destroys the account's local indexes, ends its session and leaves the page. Should
58
+ * not reject; a rejection is logged. */
59
+ finish(): Promise<void>;
43
60
  }
44
61
  export interface AppDef {
45
62
  id: AppShellApp;
@@ -67,7 +84,7 @@ export interface AppChromeProps extends AppShellProps {
67
84
  * Rendered by the webmail's app shell (`apps/www/_shell.tsx`, the router's persistent client layout), once, for the life of the
68
85
  * page, so that moving between apps replaces only `children` - see `AppShell` below for what a page's own shell renders instead.
69
86
  */
70
- export declare function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, branding: initialBranding, appearance, pluginNav, busy, hideChrome, children, }: PropsWithChildren<AppChromeProps>): React.JSX.Element;
87
+ export declare function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, branding: initialBranding, appearance, pluginNav, busy, hideChrome, signOutHost, children, }: PropsWithChildren<AppChromeProps>): React.JSX.Element;
71
88
  /**
72
89
  * What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
73
90
  * is the whole `AppChrome`, as it always was. Inside it (the router's app shell, `apps/www/_shell.tsx`) the chrome is already mounted above the
@@ -100,7 +100,7 @@ export function appRailItems(pluginNav, resolved = {}) {
100
100
  * Rendered by the webmail's app shell (`apps/www/_shell.tsx`, the router's persistent client layout), once, for the life of the
101
101
  * page, so that moving between apps replaces only `children` - see `AppShell` below for what a page's own shell renders instead.
102
102
  */
103
- export function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, branding: initialBranding, appearance, pluginNav, busy, hideChrome, children, }) {
103
+ export function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, branding: initialBranding, appearance, pluginNav, busy, hideChrome, signOutHost, children, }) {
104
104
  const client = useApiClient();
105
105
  const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
106
106
  // The keyboard shortcuts dialog: opened by `?`/Ctrl+/ (`GlobalShortcuts`) and by the account menu's item.
@@ -168,9 +168,10 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
168
168
  // own, so this is what actually removes the indexes after a console sign-out (the console also records a
169
169
  // pending deletion, retried on the next mail load, in case no mail tab is open). `destroyAllLocalIndexes()`
170
170
  // announces the sign-out on this same channel, which this tab then hears itself, so the ref is set first:
171
- // each tab reacts once, and the tab that started the sign-out ignores its own announcement.
171
+ // each tab reacts once, and the tab that started the sign-out ignores its own announcement. A host that signs out itself
172
+ // (`signOutHost`) keeps other accounts in this origin, so another tab's sign-out isn't this one's.
172
173
  useEffect(() => {
173
- if (!userUid || typeof BroadcastChannel === "undefined") {
174
+ if (!userUid || signOutHost || typeof BroadcastChannel === "undefined") {
174
175
  return;
175
176
  }
176
177
  const channel = new BroadcastChannel(SIGN_OUT_CHANNEL);
@@ -193,14 +194,19 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
193
194
  });
194
195
  });
195
196
  return () => channel.close();
196
- }, [userUid, authServerUrl]);
197
+ }, [userUid, authServerUrl, signOutHost]);
197
198
  async function handleSignOut() {
198
199
  signingOutRef.current = true;
199
200
  // Before flushing and navigating: compose windows then skip their "Leave site?" prompt, which could
200
201
  // otherwise cancel the sign-out's own navigation.
201
202
  markSigningOut();
202
- // Unlocked private keys never outlive an explicit sign-out.
203
- destroyUnlockedKeys();
203
+ // Unlocked private keys never outlive an explicit sign-out - only the signing-out account's where a host keeps others.
204
+ if (signOutHost) {
205
+ signOutHost.destroyKeys();
206
+ }
207
+ else {
208
+ destroyUnlockedKeys();
209
+ }
204
210
  // Trusted signer pins read from contacts don't outlive the session either.
205
211
  clearPinnedSignerCache();
206
212
  clearAppearanceCache();
@@ -209,6 +215,11 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
209
215
  // Open compose windows save edits still waiting on their autosave debounce while the session is still
210
216
  // valid - logout invalidates it. Bounded the same way as logout itself, and never rejects.
211
217
  await flushComposeDrafts(LOGOUT_TIMEOUT_MS);
218
+ if (signOutHost) {
219
+ // The host destroys its account's indexes (spec §11) and ends its session; this page can't tell its accounts apart.
220
+ await signOutHost.finish().catch((err) => console.warn("The host's sign-out failed:", err));
221
+ return;
222
+ }
212
223
  // The Tier 2 local index MUST be destroyed on explicit logout, the same as unlocked keys themselves
213
224
  // (spec §11). Destroys every index on this device (not only mailboxes opened this page load) and is
214
225
  // awaited before navigating - a navigation tears down the Worker mid-delete otherwise.
@@ -12,7 +12,9 @@ import { enrollKey, getKeyVault, VaultAlreadyInitializedError } from "../../../.
12
12
  import { UnopenableKeysNotice, unlockErrorMessage } from "./UnlockPromptProvider.js";
13
13
  import { FrameTakeover } from "../../navigation/frameContext.js";
14
14
  import { MIN_PASSWORD_LENGTH, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
15
- import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
15
+ import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, subscribeKeySession, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
16
+ import { ensureZeroAccessKey, provisionZeroAccessKey } from "../../../../lib/crypto/zeroAccessKey.js";
17
+ import { ensureOpenPgpKey } from "../../../../lib/crypto/openPgpKey.js";
16
18
  import { buildAad, generateMasterKey, sealWithKey } from "../../../../lib/crypto/masterKey.js";
17
19
  import { buildPasswordWrap, buildRecoveryWraps } from "../../../../lib/crypto/masterKeyWraps.js";
18
20
  import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "../../../../lib/crypto/keys.js";
@@ -27,12 +29,15 @@ async function provisionEncryptionKey(mailboxUid, mailboxAddress, password) {
27
29
  const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
28
30
  const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
29
31
  const wrappedKeySealed = await sealWithKey(mk, privateKeyRaw, buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE));
30
- await enrollKey(mailboxUid, {
32
+ const vault = await enrollKey(mailboxUid, {
31
33
  useType: "encrypt",
32
34
  csr: csrPem,
33
35
  wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
34
36
  masterKeyWraps: [passwordWrap, ...recoveryWraps],
35
37
  });
38
+ // The zero-access key (restapi specs/search.md §3), under the same master key. Not fatal: setup is done once the
39
+ // encryption key is in, and a mailbox without a zero-access key gets one on its next unlock (`ensureZeroAccessKey()`).
40
+ await provisionZeroAccessKey(mailboxUid, mailboxAddress, mk, vault.masterKeyGeneration).catch(() => undefined);
36
41
  return { recoveryCodes };
37
42
  }
38
43
  /**
@@ -90,6 +95,26 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
90
95
  cancelled = true;
91
96
  };
92
97
  }, [mailboxUid, canProvision]);
98
+ // A mailbox set up before zero-access encryption existed has no zero-access key, and one set up before OpenPGP was supported
99
+ // has no OpenPGP key (restapi specs/end-to-end_encryption.md *OpenPGP Interoperability*): give it each whenever its owner has
100
+ // it unlocked - now, or on a later unlock from anywhere (this gate, an unlock prompt, recovery, first-time setup). Best
101
+ // effort: a failure is retried on the next unlock.
102
+ useEffect(() => {
103
+ if (!mailboxUid || !mailboxAddress || !canProvision) {
104
+ return;
105
+ }
106
+ // One after the other: both enrollments update the vault, which a concurrent write would refuse (409).
107
+ const ensure = () => void ensureZeroAccessKey(mailboxUid, mailboxAddress)
108
+ .catch(() => undefined)
109
+ .then(() => ensureOpenPgpKey(mailboxUid, mailboxAddress))
110
+ .catch(() => undefined);
111
+ ensure();
112
+ return subscribeKeySession((event) => {
113
+ if (event.mailboxUid === mailboxUid && event.state === "unlocked") {
114
+ ensure();
115
+ }
116
+ });
117
+ }, [mailboxUid, mailboxAddress, canProvision]);
93
118
  async function handleUnlock(e) {
94
119
  e.preventDefault();
95
120
  setError(null);
@@ -0,0 +1,17 @@
1
+ import type { Mailbox } from "../../../../lib/mail/mailApi.js";
2
+ /** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
3
+ export declare const SHARED_MAILBOX_CHECK_INTERVAL_MS: number;
4
+ /** Forgets which refused rotations were shown (tests). */
5
+ export declare function resetSharedMailboxKeyManager(): void;
6
+ export interface SharedMailboxKeyManagerProps {
7
+ /** Every mailbox the user can open. */
8
+ mailboxes: Mailbox[];
9
+ userUid?: string;
10
+ }
11
+ /**
12
+ * Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
13
+ * unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
14
+ * password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
15
+ * silent (a pass that fails is tried again on the next), except for a rotation the server refuses, shown once as a warning. Renders nothing; mounted by `MailShell`.
16
+ */
17
+ export default function SharedMailboxKeyManager({ mailboxes, userUid }: SharedMailboxKeyManagerProps): null;
@@ -0,0 +1,98 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useRef } from "react";
6
+ import { getUnlockedKeys, personallyUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
7
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
8
+ import { notify } from "../../notifications/store.js";
9
+ /** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
10
+ export const SHARED_MAILBOX_CHECK_INTERVAL_MS = 5 * 60 * 1000;
11
+ /** The shared mailbox key code (pkijs and friends), loaded only once an own mailbox is unlocked. */
12
+ let loaded;
13
+ const loadSharedKeys = () => (loaded ?? (loaded = import("../../../../lib/crypto/sharedMailboxKeys.js").catch((err) => {
14
+ loaded = undefined;
15
+ throw err;
16
+ })));
17
+ /** The refused rotations already shown, per mailbox (the reason): each is shown once a session, not on every pass that retries it. */
18
+ const shownRefusals = new Map();
19
+ /** Forgets which refused rotations were shown (tests). */
20
+ export function resetSharedMailboxKeyManager() {
21
+ shownRefusals.clear();
22
+ }
23
+ /**
24
+ * Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
25
+ * unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
26
+ * password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
27
+ * silent (a pass that fails is tried again on the next), except for a rotation the server refuses, shown once as a warning. Renders nothing; mounted by `MailShell`.
28
+ */
29
+ export default function SharedMailboxKeyManager({ mailboxes, userUid }) {
30
+ const client = useApiClient();
31
+ const latest = useRef({ mailboxes, userUid, client });
32
+ latest.current = { mailboxes, userUid, client };
33
+ const running = useRef(false);
34
+ useEffect(() => {
35
+ async function pass() {
36
+ if (running.current || personallyUnlockedKeys().length === 0) {
37
+ return;
38
+ }
39
+ running.current = true;
40
+ try {
41
+ const { maintainSharedMailbox, unlockSharedMailbox } = await loadSharedKeys();
42
+ const { mailboxes: current, userUid: me, client: apiClient } = latest.current;
43
+ for (const mailbox of current) {
44
+ await look(mailbox, me, apiClient, unlockSharedMailbox, maintainSharedMailbox).catch(() => undefined);
45
+ }
46
+ }
47
+ catch {
48
+ // Nothing to do until the next pass.
49
+ }
50
+ finally {
51
+ running.current = false;
52
+ }
53
+ }
54
+ void pass();
55
+ const unsubscribe = subscribeKeySession((event) => {
56
+ if (event.state === "unlocked" && !getUnlockedKeys(event.mailboxUid)?.shared) {
57
+ void pass();
58
+ }
59
+ });
60
+ const interval = setInterval(() => void pass(), SHARED_MAILBOX_CHECK_INTERVAL_MS);
61
+ return () => {
62
+ unsubscribe();
63
+ clearInterval(interval);
64
+ };
65
+ }, []);
66
+ return null;
67
+ }
68
+ async function look(mailbox, me, client, unlockSharedMailbox, maintainSharedMailbox) {
69
+ const owned = !!mailbox.ownerUserUid && mailbox.ownerUserUid === me;
70
+ if (!owned) {
71
+ // Opened, or - once open - opened again when a rotation left it without the active key.
72
+ await unlockSharedMailbox(mailbox.uid, client, true);
73
+ }
74
+ const unlocked = getUnlockedKeys(mailbox.uid);
75
+ // An owner looks after their mailbox with its own keys; a member, with full access, whether or not it is open yet (a mailbox
76
+ // without an owner may have no key to open).
77
+ if (owned ? unlocked && !unlocked.shared : true) {
78
+ const done = await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
79
+ // The server kept the current key (its vault can't take the new key's member wraps): say so once, members keep reading.
80
+ if (done.rotationRefused && shownRefusals.get(mailbox.uid) !== done.rotationRefused) {
81
+ shownRefusals.set(mailbox.uid, done.rotationRefused);
82
+ notify({
83
+ kind: "warning",
84
+ title: `${mailbox.displayName || mailbox.primarySmtpAddress}: the zero-access key wasn't replaced`,
85
+ message: "A member has left, but the server refused a new key for this shared mailbox. The current key stays active.",
86
+ details: done.rotationRefused,
87
+ dedupeKey: `shared-rotation-refused:${mailbox.uid}`,
88
+ });
89
+ }
90
+ else if (!done.rotationRefused) {
91
+ shownRefusals.delete(mailbox.uid);
92
+ }
93
+ // A key this member just made (a mailbox without an owner) goes into their session now, not on the next pass.
94
+ if (!owned && (done.provisioned || done.rotated)) {
95
+ await unlockSharedMailbox(mailbox.uid, client, true);
96
+ }
97
+ }
98
+ }
@@ -36,6 +36,9 @@ interface UnlockPromptContextValue {
36
36
  * mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
37
37
  * `false` for exactly this reason.
38
38
  */
39
+ /** Messages for a shared mailbox `openShared()` can't open. */
40
+ export declare const SHARED_ACCESS_PENDING_MESSAGE = "This mailbox is shared with you, and is waiting for another member to share its key with you. Try again shortly.";
41
+ export declare const SHARED_OWN_KEYS_LOCKED_MESSAGE = "Unlock your own mailbox first: it holds the key that opens the mailboxes shared with you.";
39
42
  export declare function UnlockPromptProvider({ children }: {
40
43
  children: React.ReactNode;
41
44
  }): React.JSX.Element;
@@ -9,6 +9,7 @@ import Modal from "../../../../lib/components/overlays/Modal.js";
9
9
  import Alert from "../../../../lib/components/feedback/Alert.js";
10
10
  import Button from "../../../../lib/components/buttons/Button.js";
11
11
  import FormField from "../../../../lib/components/forms/FormField.js";
12
+ import { notify } from "../../notifications/store.js";
12
13
  import { RECOVERY_CODE_ERROR, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
13
14
  /** Shown instead of "Incorrect password" when the password was right but the mailbox's encryption key itself
14
15
  * won't open (`UnopenableEncryptionKeyError`) - retrying can't fix that, so it must not read as a typo. */
@@ -44,6 +45,33 @@ const UnlockPromptContext = createContext(null);
44
45
  * mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
45
46
  * `false` for exactly this reason.
46
47
  */
48
+ /** Messages for a shared mailbox `openShared()` can't open. */
49
+ export const SHARED_ACCESS_PENDING_MESSAGE = "This mailbox is shared with you, and is waiting for another member to share its key with you. Try again shortly.";
50
+ export const SHARED_OWN_KEYS_LOCKED_MESSAGE = "Unlock your own mailbox first: it holds the key that opens the mailboxes shared with you.";
51
+ /**
52
+ * Opens `mailboxUid` through the user's member key wraps when it is a mailbox shared with them (`sharedMailboxKeys.ts`): its keys, or
53
+ * `undefined` when it isn't one of theirs (the password prompt applies). Rejects, after saying why, when it is shared with them but can't
54
+ * open yet. The shared key code is loaded only here.
55
+ */
56
+ async function openShared(mailboxUid) {
57
+ let outcome;
58
+ try {
59
+ const { unlockSharedMailbox } = await import("../../../../lib/crypto/sharedMailboxKeys.js");
60
+ outcome = await unlockSharedMailbox(mailboxUid);
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ if (outcome === "unlocked") {
66
+ return getUnlockedKeys(mailboxUid);
67
+ }
68
+ if (outcome === "pending" || outcome === "own_keys_locked") {
69
+ const message = outcome === "pending" ? SHARED_ACCESS_PENDING_MESSAGE : SHARED_OWN_KEYS_LOCKED_MESSAGE;
70
+ notify({ kind: "info", title: "Shared mailbox", message });
71
+ throw new Error(message);
72
+ }
73
+ return undefined;
74
+ }
47
75
  export function UnlockPromptProvider({ children }) {
48
76
  const [pending, setPendingState] = useState(null);
49
77
  // Mirrors `pending` synchronously, so two requests issued in the same tick see each other.
@@ -66,6 +94,10 @@ export function UnlockPromptProvider({ children }) {
66
94
  if (existing) {
67
95
  return Promise.resolve(existing);
68
96
  }
97
+ // A mailbox shared with the user opens through their member key wraps, not a password - and its password, if it has one, isn't theirs.
98
+ return openShared(mailboxUid).then((shared) => shared ?? promptForPassword(mailboxUid, mailboxKeys));
99
+ }, []);
100
+ const promptForPassword = useCallback((mailboxUid, mailboxKeys) => {
69
101
  return new Promise((resolve, reject) => {
70
102
  const current = pendingRef.current;
71
103
  if (current?.mailboxUid === mailboxUid) {
@@ -49,7 +49,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
49
49
  let stale = false;
50
50
  void mapWithConcurrency(conversations.filter(conversationLooksEncrypted), 4, async (conversation) => {
51
51
  if (!stale) {
52
- await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
52
+ await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid, conversation.latestZeroAccess?.sealedSummary);
53
53
  }
54
54
  });
55
55
  return () => {