@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
@@ -20,9 +20,12 @@ import {
20
20
  import {
21
21
  ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE,
22
22
  getUnlockedKeys,
23
+ subscribeKeySession,
23
24
  unlockWithPassword,
24
25
  type UnlockResult,
25
26
  } from "../../../../lib/crypto/keySession.js";
27
+ import { ensureZeroAccessKey, provisionZeroAccessKey } from "../../../../lib/crypto/zeroAccessKey.js";
28
+ import { ensureOpenPgpKey } from "../../../../lib/crypto/openPgpKey.js";
26
29
  import { buildAad, generateMasterKey, sealWithKey } from "../../../../lib/crypto/masterKey.js";
27
30
  import { buildPasswordWrap, buildRecoveryWraps } from "../../../../lib/crypto/masterKeyWraps.js";
28
31
  import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "../../../../lib/crypto/keys.js";
@@ -46,13 +49,17 @@ async function provisionEncryptionKey(
46
49
  const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
47
50
  const wrappedKeySealed = await sealWithKey(mk, privateKeyRaw, buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE));
48
51
 
49
- await enrollKey(mailboxUid, {
52
+ const vault = await enrollKey(mailboxUid, {
50
53
  useType: "encrypt",
51
54
  csr: csrPem,
52
55
  wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
53
56
  masterKeyWraps: [passwordWrap, ...recoveryWraps],
54
57
  });
55
58
 
59
+ // The zero-access key (restapi specs/search.md §3), under the same master key. Not fatal: setup is done once the
60
+ // encryption key is in, and a mailbox without a zero-access key gets one on its next unlock (`ensureZeroAccessKey()`).
61
+ await provisionZeroAccessKey(mailboxUid, mailboxAddress, mk, vault.masterKeyGeneration).catch(() => undefined);
62
+
56
63
  return { recoveryCodes };
57
64
  }
58
65
 
@@ -154,6 +161,28 @@ export default function KeyEnrollmentGate({
154
161
  };
155
162
  }, [mailboxUid, canProvision]);
156
163
 
164
+ // A mailbox set up before zero-access encryption existed has no zero-access key, and one set up before OpenPGP was supported
165
+ // has no OpenPGP key (restapi specs/end-to-end_encryption.md *OpenPGP Interoperability*): give it each whenever its owner has
166
+ // it unlocked - now, or on a later unlock from anywhere (this gate, an unlock prompt, recovery, first-time setup). Best
167
+ // effort: a failure is retried on the next unlock.
168
+ useEffect(() => {
169
+ if (!mailboxUid || !mailboxAddress || !canProvision) {
170
+ return;
171
+ }
172
+ // One after the other: both enrollments update the vault, which a concurrent write would refuse (409).
173
+ const ensure = () =>
174
+ void ensureZeroAccessKey(mailboxUid, mailboxAddress)
175
+ .catch(() => undefined)
176
+ .then(() => ensureOpenPgpKey(mailboxUid, mailboxAddress))
177
+ .catch(() => undefined);
178
+ ensure();
179
+ return subscribeKeySession((event) => {
180
+ if (event.mailboxUid === mailboxUid && event.state === "unlocked") {
181
+ ensure();
182
+ }
183
+ });
184
+ }, [mailboxUid, mailboxAddress, canProvision]);
185
+
157
186
  async function handleUnlock(e: React.FormEvent) {
158
187
  e.preventDefault();
159
188
  setError(null);
@@ -0,0 +1,122 @@
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 type { Mailbox } from "../../../../lib/mail/mailApi.js";
8
+ import type { ApiClient } from "../../../../lib/util/api.js";
9
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
+ import { notify } from "../../notifications/store.js";
11
+
12
+ /** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
13
+ export const SHARED_MAILBOX_CHECK_INTERVAL_MS = 5 * 60 * 1000;
14
+
15
+ /** The shared mailbox key code (pkijs and friends), loaded only once an own mailbox is unlocked. */
16
+ let loaded: Promise<typeof import("../../../../lib/crypto/sharedMailboxKeys.js")> | undefined;
17
+ const loadSharedKeys = () =>
18
+ (loaded ??= import("../../../../lib/crypto/sharedMailboxKeys.js").catch((err) => {
19
+ loaded = undefined;
20
+ throw err;
21
+ }));
22
+
23
+ /** The refused rotations already shown, per mailbox (the reason): each is shown once a session, not on every pass that retries it. */
24
+ const shownRefusals = new Map<string, string>();
25
+
26
+ /** Forgets which refused rotations were shown (tests). */
27
+ export function resetSharedMailboxKeyManager(): void {
28
+ shownRefusals.clear();
29
+ }
30
+
31
+ export interface SharedMailboxKeyManagerProps {
32
+ /** Every mailbox the user can open. */
33
+ mailboxes: Mailbox[];
34
+ userUid?: string;
35
+ }
36
+
37
+ /**
38
+ * Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
39
+ * unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
40
+ * password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
41
+ * 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`.
42
+ */
43
+ export default function SharedMailboxKeyManager({ mailboxes, userUid }: SharedMailboxKeyManagerProps) {
44
+ const client = useApiClient();
45
+ const latest = useRef({ mailboxes, userUid, client });
46
+ latest.current = { mailboxes, userUid, client };
47
+ const running = useRef(false);
48
+
49
+ useEffect(() => {
50
+ async function pass(): Promise<void> {
51
+ if (running.current || personallyUnlockedKeys().length === 0) {
52
+ return;
53
+ }
54
+ running.current = true;
55
+ try {
56
+ const { maintainSharedMailbox, unlockSharedMailbox } = await loadSharedKeys();
57
+ const { mailboxes: current, userUid: me, client: apiClient } = latest.current;
58
+ for (const mailbox of current) {
59
+ await look(mailbox, me, apiClient, unlockSharedMailbox, maintainSharedMailbox).catch(() => undefined);
60
+ }
61
+ } catch {
62
+ // Nothing to do until the next pass.
63
+ } finally {
64
+ running.current = false;
65
+ }
66
+ }
67
+ void pass();
68
+ const unsubscribe = subscribeKeySession((event) => {
69
+ if (event.state === "unlocked" && !getUnlockedKeys(event.mailboxUid)?.shared) {
70
+ void pass();
71
+ }
72
+ });
73
+ const interval = setInterval(() => void pass(), SHARED_MAILBOX_CHECK_INTERVAL_MS);
74
+ return () => {
75
+ unsubscribe();
76
+ clearInterval(interval);
77
+ };
78
+ }, []);
79
+
80
+ return null;
81
+ }
82
+
83
+ async function look(
84
+ mailbox: Mailbox,
85
+ me: string | undefined,
86
+ client: ApiClient | undefined,
87
+ unlockSharedMailbox: (uid: string, client?: ApiClient, refresh?: boolean) => Promise<string>,
88
+ maintainSharedMailbox: (
89
+ uid: string,
90
+ address: string,
91
+ client?: ApiClient,
92
+ ) => Promise<{ provisioned: boolean; rotated: boolean; rotationRefused?: string }>,
93
+ ): Promise<void> {
94
+ const owned = !!mailbox.ownerUserUid && mailbox.ownerUserUid === me;
95
+ if (!owned) {
96
+ // Opened, or - once open - opened again when a rotation left it without the active key.
97
+ await unlockSharedMailbox(mailbox.uid, client, true);
98
+ }
99
+ const unlocked = getUnlockedKeys(mailbox.uid);
100
+ // An owner looks after their mailbox with its own keys; a member, with full access, whether or not it is open yet (a mailbox
101
+ // without an owner may have no key to open).
102
+ if (owned ? unlocked && !unlocked.shared : true) {
103
+ const done = await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
104
+ // The server kept the current key (its vault can't take the new key's member wraps): say so once, members keep reading.
105
+ if (done.rotationRefused && shownRefusals.get(mailbox.uid) !== done.rotationRefused) {
106
+ shownRefusals.set(mailbox.uid, done.rotationRefused);
107
+ notify({
108
+ kind: "warning",
109
+ title: `${mailbox.displayName || mailbox.primarySmtpAddress}: the zero-access key wasn't replaced`,
110
+ message: "A member has left, but the server refused a new key for this shared mailbox. The current key stays active.",
111
+ details: done.rotationRefused,
112
+ dedupeKey: `shared-rotation-refused:${mailbox.uid}`,
113
+ });
114
+ } else if (!done.rotationRefused) {
115
+ shownRefusals.delete(mailbox.uid);
116
+ }
117
+ // A key this member just made (a mailbox without an owner) goes into their session now, not on the next pass.
118
+ if (!owned && (done.provisioned || done.rotated)) {
119
+ await unlockSharedMailbox(mailbox.uid, client, true);
120
+ }
121
+ }
122
+ }
@@ -15,6 +15,7 @@ import Modal from "../../../../lib/components/overlays/Modal.js";
15
15
  import Alert from "../../../../lib/components/feedback/Alert.js";
16
16
  import Button from "../../../../lib/components/buttons/Button.js";
17
17
  import FormField from "../../../../lib/components/forms/FormField.js";
18
+ import { notify } from "../../notifications/store.js";
18
19
  import {
19
20
  RECOVERY_CODE_ERROR,
20
21
  RecoveryFollowUp,
@@ -98,6 +99,34 @@ const UnlockPromptContext = createContext<UnlockPromptContextValue | null>(null)
98
99
  * mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
99
100
  * `false` for exactly this reason.
100
101
  */
102
+ /** Messages for a shared mailbox `openShared()` can't open. */
103
+ 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.";
104
+ export const SHARED_OWN_KEYS_LOCKED_MESSAGE = "Unlock your own mailbox first: it holds the key that opens the mailboxes shared with you.";
105
+
106
+ /**
107
+ * Opens `mailboxUid` through the user's member key wraps when it is a mailbox shared with them (`sharedMailboxKeys.ts`): its keys, or
108
+ * `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
109
+ * open yet. The shared key code is loaded only here.
110
+ */
111
+ async function openShared(mailboxUid: string): Promise<UnlockedKeys | undefined> {
112
+ let outcome: string;
113
+ try {
114
+ const { unlockSharedMailbox } = await import("../../../../lib/crypto/sharedMailboxKeys.js");
115
+ outcome = await unlockSharedMailbox(mailboxUid);
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ if (outcome === "unlocked") {
120
+ return getUnlockedKeys(mailboxUid);
121
+ }
122
+ if (outcome === "pending" || outcome === "own_keys_locked") {
123
+ const message = outcome === "pending" ? SHARED_ACCESS_PENDING_MESSAGE : SHARED_OWN_KEYS_LOCKED_MESSAGE;
124
+ notify({ kind: "info", title: "Shared mailbox", message });
125
+ throw new Error(message);
126
+ }
127
+ return undefined;
128
+ }
129
+
101
130
  export function UnlockPromptProvider({ children }: { children: React.ReactNode }) {
102
131
  const [pending, setPendingState] = useState<PendingUnlock | null>(null);
103
132
  // Mirrors `pending` synchronously, so two requests issued in the same tick see each other.
@@ -122,6 +151,11 @@ export function UnlockPromptProvider({ children }: { children: React.ReactNode }
122
151
  if (existing) {
123
152
  return Promise.resolve(existing);
124
153
  }
154
+ // 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.
155
+ return openShared(mailboxUid).then((shared) => shared ?? promptForPassword(mailboxUid, mailboxKeys));
156
+ }, []);
157
+
158
+ const promptForPassword = useCallback((mailboxUid: string, mailboxKeys: PublicKey[]): Promise<UnlockedKeys> => {
125
159
  return new Promise<UnlockedKeys>((resolve, reject) => {
126
160
  const current = pendingRef.current;
127
161
  if (current?.mailboxUid === mailboxUid) {
@@ -125,7 +125,7 @@ export default function ConversationList({
125
125
  let stale = false;
126
126
  void mapWithConcurrency(conversations.filter(conversationLooksEncrypted), 4, async (conversation) => {
127
127
  if (!stale) {
128
- await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
128
+ await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid, conversation.latestZeroAccess?.sealedSummary);
129
129
  }
130
130
  });
131
131
  return () => {
@@ -15,6 +15,7 @@ import {
15
15
  respondToMessageInvite,
16
16
  } from "../../../../lib/calendar/inviteApi.js";
17
17
  import { guestPermissionsOf } from "../../../../lib/calendar/calendarApi.js";
18
+ import { openSealedEvent } from "../../calendar/sealedEvents.js";
18
19
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
19
20
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
20
21
  import Button from "../../../../lib/components/buttons/Button.js";
@@ -334,7 +335,7 @@ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardP
334
335
  aria-busy={pending === "acceptProposal" || undefined}
335
336
  loading={pending === "acceptProposal"}
336
337
  disabled={busy}
337
- onClick={() => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client))}
338
+ onClick={() => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client, invite.calendarEventUid, openSealedEvent))}
338
339
  >
339
340
  Accept proposal
340
341
  </Button>
@@ -387,6 +388,15 @@ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardP
387
388
  description: invite.description,
388
389
  descriptionHtml: invite.descriptionHtml,
389
390
  guestAddresses: invite.attendees.map((attendee) => attendee.address),
391
+ // The invitation's own content: what a protected copy of the event is named by.
392
+ invitationContent: invite.summary
393
+ ? {
394
+ title: invite.summary,
395
+ location: invite.location ?? undefined,
396
+ description: invite.description ?? undefined,
397
+ descriptionHtml: invite.descriptionHtml ?? undefined,
398
+ }
399
+ : undefined,
390
400
  }}
391
401
  canChange={!!invite.canRequestChange}
392
402
  canInvite={!!invite.canRequestInvite}
@@ -48,12 +48,13 @@ import MoveToFolderDialog from "./MoveToFolderDialog.js";
48
48
  import InviteCard from "./InviteCard.js";
49
49
  import { isCalendarAttachment } from "./invite/inviteFormat.js";
50
50
  import { useMessageInvite } from "./invite/inviteStore.js";
51
+ import { rememberSealedInvite } from "../../../../lib/calendar/sealedInvites.js";
51
52
  import { getUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
52
- import type { MessageSecurityResult, SignatureFailureReason } from "../../../../lib/crypto/messageSecurity.js";
53
+ import type { MessageSecurityResult, SignatureFailureReason, SignerPinsInput } from "../../../../lib/crypto/messageSecurity.js";
53
54
  import { extractAddresses, type MimeAttachment } from "../../../../lib/crypto/mime.js";
54
55
  import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "../../../../lib/crypto/keyvaultApi.js";
55
56
  import { isLikelyMailingList } from "../../../../lib/crypto/composeSecurity.js";
56
- import { SenderKeyState, clearPinnedSignerCache, getPinnedSignerFingerprints, getSignerKeyState } from "./pinnedSigners.js";
57
+ import { SenderKeyState, clearPinnedSignerCache, getPinnedSigners, getSignerKeyState } from "./pinnedSigners.js";
57
58
  import { currentVerificationSeal, getVaultGeneration, sendVerificationSeal } from "./verificationSeals.js";
58
59
  import { getMyMailboxAccess } from "../../../../lib/mail/mailboxAccessApi.js";
59
60
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
@@ -116,6 +117,9 @@ const SECURITY_INDICATOR: Record<MessageSecurityResult["state"], { label: string
116
117
  // Verified when first opened (a verification seal), but not live: muted, never the green verified pill. A signer key
117
118
  // later reported compromised switches it to `LATER_COMPROMISED_INDICATOR_CLASS`.
118
119
  verified_at_first_open: { label: "Verified when first opened", className: "bg-surface-alt text-text-muted" },
120
+ // Sealed by the server on arrival (zero-access): distinct from "Encrypted" - the server held this message in the clear while it
121
+ // delivered it - and never worded as end-to-end, per the spec's indicator table.
122
+ protected_at_rest: { label: "Protected at rest", className: "bg-primary/5 text-text" },
119
123
  };
120
124
 
121
125
  /** The amber `verified_at_first_open` badge for a signer key since reported compromised. */
@@ -289,11 +293,14 @@ function IconAction({
289
293
  );
290
294
  }
291
295
 
292
- function SecurityIndicator({ security }: { security: MessageSecurityResult }) {
293
- const { label, className } = SECURITY_INDICATOR[security.state];
296
+ function SecurityIndicator({ security, zeroAccess }: { security: MessageSecurityResult; zeroAccess?: boolean }) {
297
+ // A zero-access message this device can't open yet (keys locked) evaluates as an encrypted one it has no key for: it is still
298
+ // labelled for what it is.
299
+ const state = zeroAccess && security.state === "encrypted" && security.decryptError !== undefined ? "protected_at_rest" : security.state;
300
+ const { label, className } = SECURITY_INDICATOR[state];
294
301
  if (security.state !== "verified_at_first_open") {
295
302
  // Every encrypted state carries a small lock, so the badge reads at a glance; the words are unchanged.
296
- const encrypted = security.state.startsWith("encrypted");
303
+ const encrypted = state.startsWith("encrypted") || state === "protected_at_rest";
297
304
  return (
298
305
  <span className={`inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`}>
299
306
  {encrypted && <HiOutlineLockClosed size={12} aria-hidden="true" />}
@@ -473,7 +480,7 @@ export default function MessageDetailPane(props: MessageDetailPaneProps) {
473
480
  * surfaced as an attachment - so a message with neither flag can't carry a signature to verify, and
474
481
  * fetching its full raw source (attachments included) would be pure waste. */
475
482
  function needsRawSecurityEvaluation(message: Message): boolean {
476
- return !!message.encrypted || message.hasAttachments;
483
+ return !!message.encrypted || !!message.zeroAccess || message.hasAttachments;
477
484
  }
478
485
 
479
486
  function MessageDetailContent({
@@ -605,9 +612,9 @@ function MessageDetailContent({
605
612
  // mailbox's own signing keys when it sent the message itself. A failed lookup means no pins, which can only
606
613
  // ever make a signature "signer not verified", never verified.
607
614
  const senderAddress = message.from.address;
608
- const pinsPromise = getPinnedSignerFingerprints(message.mailboxUid, senderAddress, client).then(
609
- (fingerprints) => ({ loaded: true, fingerprints }),
610
- () => ({ loaded: false, fingerprints: [] as string[] }),
615
+ const pinsPromise = getPinnedSigners(message.mailboxUid, senderAddress, client).then(
616
+ (signers) => ({ loaded: true, ...signers }),
617
+ () => ({ loaded: false, fingerprints: [] as string[], openPgpKeys: [] as string[] }),
611
618
  );
612
619
  // Verification seals (see `verificationSeals.ts`) need unlocked keys and the vault's current master key
613
620
  // generation; without either the message is evaluated exactly as before, with no seal. The sender's key records
@@ -633,7 +640,9 @@ function MessageDetailContent({
633
640
  const ownPins = signingKeyFingerprints(ownKeys);
634
641
  const [contactPins, sealContext] = await Promise.all([pinsPromise, sealContextPromise]);
635
642
  const pins = [...new Set([...contactPins.fingerprints, ...ownPins])];
636
- const pinned = pins.length > 0 ? pins : undefined;
643
+ // An OpenPGP signature is checked against the sender's pinned OpenPGP certificates themselves (it names only a key ID).
644
+ const pinned: SignerPinsInput =
645
+ contactPins.openPgpKeys.length > 0 ? { fingerprints: pins, openPgpKeys: contactPins.openPgpKeys } : pins.length > 0 ? pins : undefined;
637
646
  // The raw content is a byte string - only ever handed to evaluateMessageSecurity(), never shown. Keys
638
647
  // are read after the pin lookup's await, so a lock that happened meanwhile is honored.
639
648
  const unlocked = getUnlockedKeys(message.mailboxUid);
@@ -670,7 +679,7 @@ function MessageDetailContent({
670
679
  const viaAlias = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
671
680
  result = { ...result, notAddressedToReader: viaAlias.notAddressedToReader };
672
681
  }
673
- const unpinned = contactPins.loaded && pins.length === 0;
682
+ const unpinned = contactPins.loaded && pins.length === 0 && contactPins.openPgpKeys.length === 0;
674
683
  const keyChanged = signerKeyChanged(result);
675
684
  const [keyState, access] = await Promise.all([
676
685
  keyChanged || (unpinned && UNVERIFIED_SIGNER_STATES.has(result.state)) ? loadKeyState() : undefined,
@@ -686,14 +695,14 @@ function MessageDetailContent({
686
695
  .catch(() => {
687
696
  if (!cancelled) {
688
697
  setSecurity(
689
- message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
698
+ message.encrypted || message.zeroAccess ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
690
699
  );
691
700
  }
692
701
  });
693
702
  return () => {
694
703
  cancelled = true;
695
704
  };
696
- }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
705
+ }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, message.zeroAccess?.keyFingerprint, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
697
706
 
698
707
  // Whether the body is the locked state right now (an encrypted message this device holds no unlocked keys for), for the subscription below.
699
708
  const lockedRef = useRef(false);
@@ -776,11 +785,11 @@ function MessageDetailContent({
776
785
  // Only ever invoked from the "Trust this signer" dialog, which only opens for an unverified-signer result carrying
777
786
  // `signerCertificate`. The pin goes on the contact in the mailbox the message belongs to; the evaluation is then
778
787
  // re-run against a fresh pinned-signer lookup, so the badge turns verified.
779
- async function handleTrustSigner(address: string, certificate: string) {
788
+ async function handleTrustSigner(address: string, certificate: string, type?: "openpgp") {
780
789
  setTrusting(true);
781
790
  setTrustError(null);
782
791
  try {
783
- await trustSigner(message.mailboxUid, { address, certificate }, client);
792
+ await trustSigner(message.mailboxUid, { address, certificate, ...(type ? { type } : {}) }, client);
784
793
  clearPinnedSignerCache();
785
794
  setTrustConfirmOpen(false);
786
795
  setUnlockRefresh((n) => n + 1);
@@ -1078,7 +1087,11 @@ function MessageDetailContent({
1078
1087
  // listed with a warning. A signed-only failure never carries recovered attachments.
1079
1088
  const innerAttachments =
1080
1089
  security?.attachments !== undefined &&
1081
- (verifiedOrSealed || security.state === "encrypted" || security.state === "encrypted_unverified_signer" || security.state === "signature_failed")
1090
+ (verifiedOrSealed ||
1091
+ security.zeroAccess ||
1092
+ security.state === "encrypted" ||
1093
+ security.state === "encrypted_unverified_signer" ||
1094
+ security.state === "signature_failed")
1082
1095
  ? security.attachments
1083
1096
  : undefined;
1084
1097
  // Any state with a valid signature shows the address actually signed for (the protected From when the message
@@ -1101,7 +1114,10 @@ function MessageDetailContent({
1101
1114
  ? security.signerCertificate
1102
1115
  : undefined;
1103
1116
  const keyChanged = security && signerKeyChanged(security) ? security : undefined;
1104
- const pinnedSignerKey = senderKeyState?.pinned[0];
1117
+ // An OpenPGP signer's key is pinned as the contact's OpenPGP certificate, not a signing certificate.
1118
+ const openPgpSigner = keyChanged?.signerKeyType === "openpgp";
1119
+ const pinnedSignerKey = openPgpSigner ? senderKeyState?.openPgp?.[0] : senderKeyState?.pinned[0];
1120
+ const pinnedSignerSince = openPgpSigner ? senderKeyState?.openPgpPinnedSince : senderKeyState?.pinnedSince;
1105
1121
  const recordedConflict =
1106
1122
  keyChanged && sameFingerprint(senderKeyState?.conflict?.observedKey.fingerprint, keyChanged.signerFingerprint)
1107
1123
  ? senderKeyState!.conflict
@@ -1132,7 +1148,18 @@ function MessageDetailContent({
1132
1148
  // Only a message the server can read and that carries a calendar file is worth asking about an invitation. An encrypted message is out: the
1133
1149
  // server holds only its ciphertext (the calendar file is inside it), so there is nothing for it to find. Drafts and Outbox hold messages
1134
1150
  // that are being written or sent, not received invitations.
1135
- const mayHoldInvite = !message.encrypted && !inOutbox && message.folderUid !== draftsFolderUid && attachments.some(isCalendarAttachment);
1151
+ // A zero-access message's calendar file is inside the sealed copy: the server reads the invitation from the one decrypted here.
1152
+ const sealedCalendar: string | undefined = security?.zeroAccess ? security.calendar : undefined;
1153
+ // Registered while rendering, not in an effect: the invitation lookup below must find it the moment it is enabled. Writing the same
1154
+ // file for the same message again is a no-op.
1155
+ if (sealedCalendar !== undefined) {
1156
+ rememberSealedInvite(message.uid, sealedCalendar, message.mailboxUid);
1157
+ }
1158
+ const mayHoldInvite =
1159
+ !message.encrypted &&
1160
+ !inOutbox &&
1161
+ message.folderUid !== draftsFolderUid &&
1162
+ (attachments.some(isCalendarAttachment) || sealedCalendar !== undefined);
1136
1163
  // The calendar file of a message whose invitation card is drawn is the card, not a chip in the attachment list (a nameless "attachment" to click):
1137
1164
  // it is listed again if the card could not be drawn (no invitation the server can read, or the lookup failed), so the file is never out of reach.
1138
1165
  const { invite: shownInvite } = useMessageInvite(message.uid, mayHoldInvite);
@@ -1247,7 +1274,7 @@ function MessageDetailContent({
1247
1274
  /** The rules page's "New mail filter", started from this message: its sender and (for a message the server can read) its subject. */
1248
1275
  function handleCreateRule() {
1249
1276
  const params = new URLSearchParams({ mailboxUid: message.mailboxUid, from: message.from.address });
1250
- const subject = message.encrypted ? "" : withoutSubjectPrefixes(message.subject);
1277
+ const subject = message.encrypted || message.zeroAccess ? "" : withoutSubjectPrefixes(message.subject);
1251
1278
  if (subject) {
1252
1279
  params.set("subject", subject);
1253
1280
  }
@@ -1273,7 +1300,7 @@ function MessageDetailContent({
1273
1300
  createRule: handleCreateRule,
1274
1301
  };
1275
1302
  // An encrypted message can be printed once it has been opened here: while it is still opening, or when it can't be, there is nothing to print.
1276
- const printReason = !message.encrypted
1303
+ const printReason = !message.encrypted && !message.zeroAccess
1277
1304
  ? undefined
1278
1305
  : security === null
1279
1306
  ? "Still opening this message"
@@ -1325,8 +1352,13 @@ function MessageDetailContent({
1325
1352
  </ParticipantLink>
1326
1353
  </p>
1327
1354
  )}
1328
- {security && <SecurityIndicator security={security} />}
1355
+ {security && <SecurityIndicator security={security} zeroAccess={!!message.zeroAccess} />}
1329
1356
  </div>
1357
+ {security?.signerKeyUnknown && (
1358
+ <p role="status" className="mt-2 text-sm text-text-muted">
1359
+ Signed with an OpenPGP key RapidMX doesn&rsquo;t have for this sender yet, so the signature couldn&rsquo;t be checked.
1360
+ </p>
1361
+ )}
1330
1362
  {senderNameCheck.misleading && (
1331
1363
  <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
1332
1364
  The sender&rsquo;s name &ldquo;{senderName}&rdquo; looks like an email address, but this message was
@@ -1555,9 +1587,10 @@ function MessageDetailContent({
1555
1587
  <KeyChangeReview
1556
1588
  mailboxUid={message.mailboxUid}
1557
1589
  address={senderAddress}
1558
- useType="sign"
1590
+ useType={openPgpSigner ? "encrypt" : "sign"}
1591
+ {...(openPgpSigner ? { keyType: "openpgp" as const } : {})}
1559
1592
  ownerName="the sender"
1560
- current={pinnedSignerKey && { fingerprint: pinnedSignerKey.fingerprint, since: senderKeyState.pinnedSince! }}
1593
+ current={pinnedSignerKey && { fingerprint: pinnedSignerKey.fingerprint, since: pinnedSignerSince! }}
1561
1594
  proposed={{
1562
1595
  fingerprint: keyChanged.signerFingerprint!,
1563
1596
  emails: keyChanged.signerEmails,
@@ -1711,7 +1744,7 @@ function MessageDetailContent({
1711
1744
  body never came through the server's own sanitizer (the server never saw the plaintext); `MessageBody` sanitizes every body
1712
1745
  client-side regardless, behind a no-remote-loads CSP, in a frame that runs no script. */}
1713
1746
  <div ref={bodyRegionRef} tabIndex={-1} className="outline-none">
1714
- {message.encrypted && security === null ? (
1747
+ {(message.encrypted || message.zeroAccess) && security === null ? (
1715
1748
  <BodySkeleton />
1716
1749
  ) : security?.decryptError !== undefined ? (
1717
1750
  // An encrypted message that could not be read: locked (no unlocked keys - the button asks the app's own unlock prompt, and the message
@@ -1804,7 +1837,7 @@ function MessageDetailContent({
1804
1837
  onClose={closeSource}
1805
1838
  mode={sourceView?.mode ?? "source"}
1806
1839
  source={sourceView?.source ?? { status: "loading" }}
1807
- encrypted={!!message.encrypted}
1840
+ encrypted={!!message.encrypted || !!message.zeroAccess}
1808
1841
  />
1809
1842
  <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
1810
1843
  <p className="text-sm text-text-muted mb-4">
@@ -1857,7 +1890,7 @@ function MessageDetailContent({
1857
1890
  className="!w-auto"
1858
1891
  loading={trusting}
1859
1892
  disabled={trusting}
1860
- onClick={() => handleTrustSigner(senderAddress, trustableCertificate!)}
1893
+ onClick={() => handleTrustSigner(senderAddress, trustableCertificate!, security?.signerKeyType)}
1861
1894
  >
1862
1895
  Trust
1863
1896
  </Button>
@@ -48,7 +48,7 @@ import { notify } from "../../../notifications/store.js";
48
48
  import { isFileDrag, isInlineImage } from "./dropFiles.js";
49
49
  import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
50
50
  import { decideSend, SendBlock } from "../../../mail/outbox/sendDecision.js";
51
- import { SendRequest, sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
51
+ import { SendRequest, sendDecisionInput, senderCanUseOpenPgp, startSend } from "../../../mail/outbox/sendJob.js";
52
52
  import { useCompose, type ComposeSession } from "./ComposeContext.js";
53
53
  import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
54
54
  import { SHORTCUTS, ShortcutDef } from "../../../keyboard/keymap.js";
@@ -492,7 +492,14 @@ export default function ComposeWindow({
492
492
  return;
493
493
  }
494
494
  lookupFailuresRef.current.delete(key);
495
- const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
495
+ const status = resolveRecipientEncryption(
496
+ mailbox.primarySmtpAddress,
497
+ ownPrefersMutual,
498
+ encryptionPolicy,
499
+ recipient.address,
500
+ result.lookup,
501
+ senderCanUseOpenPgp(getUnlockedKeys(mailbox.uid)),
502
+ );
496
503
  setRecipientStatuses((prev) => ({ ...prev, [key]: status }));
497
504
  });
498
505
  }
@@ -5,6 +5,7 @@
5
5
  import React, { ChangeEvent, KeyboardEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
6
6
  import { createPortal } from "react-dom";
7
7
  import { HiOutlineXMark } from "react-icons/hi2";
8
+ import { fetchSuggestionsWithProtectedContacts } from "./protectedContactSuggestions.js";
8
9
  import {
9
10
  fetchRecipientSuggestions,
10
11
  RECIPIENT_SUGGESTION_MIN_QUERY_LENGTH,
@@ -111,7 +112,7 @@ export default function RecipientInput({
111
112
  onBlur,
112
113
  onCommit,
113
114
  mailboxUid,
114
- fetchSuggestions = fetchRecipientSuggestions,
115
+ fetchSuggestions = fetchSuggestionsWithProtectedContacts,
115
116
  debounceMs = RECIPIENT_SUGGESTION_DEBOUNCE_MS,
116
117
  disabled,
117
118
  autoFocus,
@@ -2,7 +2,9 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { type Message, type Recipient, type RecipientType, listAttachments } from "../../../../../lib/mail/mailApi.js";
5
+ import { getUnlockedKeys } from "../../../../../lib/crypto/keySession.js";
6
+ import { evaluateMessageSecurity, type MessageSecurityResult } from "../../../../../lib/crypto/messageSecurity.js";
7
+ import { getMessageRawContent, type Message, type Recipient, type RecipientType, listAttachments } from "../../../../../lib/mail/mailApi.js";
6
8
  import { formatMailAddress } from "../../../../../lib/mail/mailAddress.js";
7
9
  import type { ApiClient } from "../../../../../lib/util/api.js";
8
10
  import type { ResumeCompose } from "../../../mail/outbox/composeBridge.js";
@@ -29,13 +31,22 @@ function textToHtml(text: string): string {
29
31
  * What the compose window needs to continue `draft` - a message in the Drafts folder the reader opened rather than one a window of this tab
30
32
  * saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
31
33
  * and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
34
+ * A draft saved in a zero-access mailbox is stored sealed to the mailbox's zero-access key: it is decrypted here with the unlocked keys (its
35
+ * subject comes from the decrypted message too, the record holding only `[...]`), and is `undefined` - left to read - while they're locked.
32
36
  * Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
33
37
  */
34
38
  export async function resumeFromDraft(draft: Message, client?: ApiClient): Promise<ResumeCompose | undefined> {
35
39
  if (draft.encrypted) {
36
40
  return undefined;
37
41
  }
38
- const [original, attachments] = await Promise.all([loadOriginalMessage(draft, null), listAttachments(draft.folderUid, draft.uid, client)]);
42
+ let security: MessageSecurityResult | null = null;
43
+ if (draft.zeroAccess) {
44
+ security = await openSealedDraft(draft, client);
45
+ if (!security) {
46
+ return undefined;
47
+ }
48
+ }
49
+ const [original, attachments] = await Promise.all([loadOriginalMessage(draft, security), listAttachments(draft.folderUid, draft.uid, client)]);
39
50
  const html =
40
51
  original.body.html !== undefined ? original.body.html : original.body.text !== undefined ? textToHtml(original.body.text) : textToHtml(draft.bodyPreview ?? "");
41
52
  return {
@@ -44,7 +55,7 @@ export async function resumeFromDraft(draft: Message, client?: ApiClient): Promi
44
55
  to: recipientText(draft.recipients, "to"),
45
56
  cc: recipientText(draft.recipients, "cc"),
46
57
  bcc: recipientText(draft.recipients, "bcc"),
47
- subject: draft.subject,
58
+ subject: security?.subject ?? draft.subject,
48
59
  html,
49
60
  attachments,
50
61
  requestReceipt: !!draft.requestReceipt,
@@ -52,3 +63,17 @@ export async function resumeFromDraft(draft: Message, client?: ApiClient): Promi
52
63
  encryptRequested: false,
53
64
  };
54
65
  }
66
+
67
+ /** `draft`'s sealed message decrypted with its mailbox's unlocked keys, or `null` when they're locked or it won't open with them. */
68
+ async function openSealedDraft(draft: Message, client?: ApiClient): Promise<MessageSecurityResult | null> {
69
+ const unlocked = getUnlockedKeys(draft.mailboxUid);
70
+ if (!unlocked) {
71
+ return null;
72
+ }
73
+ try {
74
+ const security = await evaluateMessageSecurity(await getMessageRawContent(draft.uid, client), unlocked);
75
+ return security.zeroAccess ? security : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }