@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
@@ -0,0 +1,121 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Recipient suggestions that include contacts zero-access encryption protects. Their names are sealed (restapi `specs/search.md` §3 *What
7
+ * It Covers*), so the server's contact search finds them only by address; this matches their opened names on the device and puts them
8
+ * first. Names are opened only - never the rest of a contact - and kept for ten minutes, and only while the mailbox is unlocked: its lock
9
+ * (and signing out) forgets them, as does a search that finds it locked. Reading them never holds up the server's suggestions: what isn't read by the time those arrive (plus
10
+ * `PROTECTED_NAMES_WAIT_MS`) is left to the next keystroke.
11
+ */
12
+ import { type Contact, listContacts, PINNED_FINGERPRINT_PAGE_SIZE } from "../../../../../lib/contacts/contactsApi.js";
13
+ import { getUnlockedKeys, subscribeKeySession } from "../../../../../lib/crypto/keySession.js";
14
+ import {
15
+ type ContactSuggestionOptions,
16
+ fetchRecipientSuggestions,
17
+ mergeRecipientSuggestions,
18
+ RECIPIENT_SUGGESTION_DEFAULT_LIMIT,
19
+ type RecipientSuggestion,
20
+ } from "../../../../../lib/mail/directoryApi.js";
21
+ import { listFolders } from "../../../../../lib/mail/mailApi.js";
22
+ import type { ApiClient } from "../../../../../lib/util/api.js";
23
+ import { isProtectedRecord, openSealedRecords } from "../../../../../lib/zeroAccess/sealedRecords.js";
24
+ import { onSigningOut } from "./composeFlushRegistry.js";
25
+
26
+ /** How long a mailbox's protected contact names are kept: reading them is a crawl of the whole address book. */
27
+ export const PROTECTED_NAMES_TTL_MS = 10 * 60_000;
28
+
29
+ /** How long suggestions wait, once the server's have arrived, for the protected contacts still being read. */
30
+ export const PROTECTED_NAMES_WAIT_MS = 250;
31
+
32
+ /** At most this many contacts are read per contacts folder. */
33
+ const MAX_PAGES = 20;
34
+
35
+ const cache = new Map<string, { loadedAt: number; contacts: Promise<Contact[]> }>();
36
+
37
+ /** Forgets every mailbox's names (signing out, or the test seam). */
38
+ export function clearProtectedContactNames(): void {
39
+ cache.clear();
40
+ }
41
+
42
+ onSigningOut(clearProtectedContactNames);
43
+
44
+ let watchingKeys = false;
45
+
46
+ /** A mailbox's names go the moment it locks, so nothing opened outlives its key session. Subscribed with the first crawl, not on import. */
47
+ function forgetOnLock(): void {
48
+ if (watchingKeys) {
49
+ return;
50
+ }
51
+ watchingKeys = true;
52
+ subscribeKeySession(({ mailboxUid, state }) => {
53
+ if (state === "locked") {
54
+ cache.delete(mailboxUid);
55
+ }
56
+ });
57
+ }
58
+
59
+ /** `mailboxUid`'s protected contacts with their names opened. */
60
+ async function loadProtectedContacts(mailboxUid: string, client: ApiClient | undefined): Promise<Contact[]> {
61
+ const found: Contact[] = [];
62
+ // The folders side by side, each one's pages in turn.
63
+ await Promise.all(
64
+ (await listFolders(mailboxUid, client))
65
+ .filter((f) => f.type === "contacts")
66
+ .map(async (folder) => {
67
+ for (let page = 0; page < MAX_PAGES; page++) {
68
+ const batch = await listContacts(folder.uid, { limit: PINNED_FINGERPRINT_PAGE_SIZE, page }, client);
69
+ found.push(...batch.filter((contact) => isProtectedRecord(contact)));
70
+ if (batch.length < PINNED_FINGERPRINT_PAGE_SIZE) {
71
+ break;
72
+ }
73
+ }
74
+ }),
75
+ );
76
+ return openSealedRecords(found, ["displayName"]);
77
+ }
78
+
79
+ function protectedContacts(mailboxUid: string, client: ApiClient | undefined): Promise<Contact[]> {
80
+ let entry = cache.get(mailboxUid);
81
+ if (!entry || Date.now() - entry.loadedAt > PROTECTED_NAMES_TTL_MS) {
82
+ forgetOnLock();
83
+ const created = { loadedAt: Date.now(), contacts: loadProtectedContacts(mailboxUid, client) };
84
+ created.contacts.catch(() => cache.delete(mailboxUid));
85
+ cache.set(mailboxUid, created);
86
+ entry = created;
87
+ }
88
+ return entry.contacts;
89
+ }
90
+
91
+ /** Every address of the protected contacts of `mailboxUid` whose opened name contains `query` (case-insensitively). */
92
+ export async function searchProtectedContactNames(mailboxUid: string, query: string, client?: ApiClient): Promise<RecipientSuggestion[]> {
93
+ const wanted = query.trim().toLowerCase();
94
+ if (wanted.length < 2) {
95
+ return [];
96
+ }
97
+ if (!getUnlockedKeys(mailboxUid)?.zeroAccessKeys?.length) {
98
+ // Locked: nothing opened is kept, and nothing could be opened now.
99
+ cache.delete(mailboxUid);
100
+ return [];
101
+ }
102
+ return (await protectedContacts(mailboxUid, client))
103
+ .filter((contact) => contact.displayName.toLowerCase().includes(wanted))
104
+ .flatMap((contact) => contact.emails.map((email) => ({ displayName: contact.displayName, address: email.address, kind: "contact" as const })));
105
+ }
106
+
107
+ /** `fetchRecipientSuggestions()` with the composing mailbox's protected contacts matched by name on the device, first - those read by the
108
+ * time the server's suggestions are in (plus `PROTECTED_NAMES_WAIT_MS`): a first search of a large address book shows the server's alone
109
+ * rather than waiting for the crawl, which goes on for the next keystroke. A failure to read them only leaves them out. */
110
+ export async function fetchSuggestionsWithProtectedContacts(
111
+ query: string,
112
+ options: ContactSuggestionOptions = {},
113
+ client?: ApiClient,
114
+ ): Promise<RecipientSuggestion[]> {
115
+ const local: Promise<RecipientSuggestion[]> = options.mailboxUid ? searchProtectedContactNames(options.mailboxUid, query, client).catch(() => []) : Promise.resolve([]);
116
+ const server = await fetchRecipientSuggestions(query, options, client);
117
+ let timer: ReturnType<typeof setTimeout> | undefined;
118
+ const ready = await Promise.race([local, new Promise<RecipientSuggestion[]>((resolve) => (timer = setTimeout(() => resolve([]), PROTECTED_NAMES_WAIT_MS)))]);
119
+ clearTimeout(timer);
120
+ return mergeRecipientSuggestions(ready, server, options.limit ?? RECIPIENT_SUGGESTION_DEFAULT_LIMIT);
121
+ }
@@ -94,10 +94,10 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
94
94
  /**
95
95
  * Starts fetching what a Reply/Forward of `message` will quote, without waiting for the click - called when the pointer or
96
96
  * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
97
- * Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
97
+ * Nothing to fetch for an encrypted or zero-access message (`loadOriginalMessage()` never asks the server for one's body).
98
98
  */
99
99
  export function prefetchOriginalMessage(message: Message, client?: ApiClient): void {
100
- if (!message.encrypted) {
100
+ if (!message.encrypted && !message.zeroAccess) {
101
101
  void fetchContentHtml(message.uid, client);
102
102
  }
103
103
  }
@@ -201,7 +201,8 @@ export async function loadOriginalMessage(
201
201
  ? [...headerRecipients(protectedHeaders.to, "to"), ...headerRecipients(protectedHeaders.cc, "cc")]
202
202
  : [];
203
203
  const result: OriginalMessage = { body: recovered ?? {}, ...(protectedRecipients.length > 0 ? { recipients: protectedRecipients } : {}) };
204
- if (message.encrypted) {
204
+ // The server holds only ciphertext of an encrypted or zero-access message: its body is what `security` decrypted, or nothing.
205
+ if (message.encrypted || message.zeroAccess) {
205
206
  return result;
206
207
  }
207
208
  let needsRaw = !!options.recipients && result.recipients === undefined;
@@ -4,6 +4,8 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { useCallback, useEffect, useReducer } from "react";
6
6
  import { MessageInvite, getMessageInvite } from "../../../../../lib/calendar/inviteApi.js";
7
+ import { clearSealedInvites, onSealedInvitesForgotten, sealedInviteCalendar } from "../../../../../lib/calendar/sealedInvites.js";
8
+ import { onSigningOut } from "../compose/composeFlushRegistry.js";
7
9
  import { ApiClient } from "../../../../../lib/util/api.js";
8
10
  import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
9
11
 
@@ -51,7 +53,9 @@ function normalize(invite: MessageInvite | null): MessageInvite | null {
51
53
  */
52
54
  export function loadInvite(uid: string, client?: ApiClient): Promise<MessageInvite | null> {
53
55
  const existing = entries.get(uid);
54
- if (existing && (existing.value === undefined || Date.now() - existing.at < INVITE_TTL_MS)) {
56
+ // "No invitation" for a zero-access message is asked again once its calendar file has been decrypted here (the list asks before that).
57
+ const stale = existing?.value === null && sealedInviteCalendar(uid) !== undefined;
58
+ if (existing && !stale && (existing.value === undefined || Date.now() - existing.at < INVITE_TTL_MS)) {
55
59
  return existing.promise;
56
60
  }
57
61
  const entry: Entry = {
@@ -120,3 +124,15 @@ export function useMessageInvite(uid: string, enabled = true): { invite: Message
120
124
  const value = enabled ? entries.get(uid)?.value : undefined;
121
125
  return { invite: value ?? null, settled: value !== undefined, setInvite };
122
126
  }
127
+
128
+ onSigningOut(clearSealedInvites);
129
+ onSigningOut(clearInviteCache);
130
+ // What the server read from a zero-access message's decrypted calendar file (its summary, description) goes with the file when the mailbox
131
+ // locks: mounted cards, chips and popovers draw nothing until it is read again.
132
+ onSealedInvitesForgotten((uids) => {
133
+ for (const uid of uids) {
134
+ if (entries.delete(uid)) {
135
+ notify(uid);
136
+ }
137
+ }
138
+ });
@@ -15,6 +15,7 @@ import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
15
15
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
16
  import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
17
17
  import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
18
+ import SharedMailboxKeyManager from "../../layout/SharedMailboxKeyManager.js";
18
19
  import { LiveUpdates, NO_LIVE_UPDATES } from "../../../mail/useMailLiveUpdates.js";
19
20
  import { MAILBOX_LIST_LIMIT, MailConnectionContext, useMailConnection } from "../../../mail/useMailConnection.js";
20
21
  import { folderRows } from "../../../mail/folderTree.js";
@@ -538,6 +539,7 @@ export default function MailShell({
538
539
  // the impersonated user's own mailbox: an administrator must never choose its password/recovery codes.
539
540
  canProvision={!impersonating && activeMailbox?.ownerUserUid === userUid}
540
541
  >
542
+ <SharedMailboxKeyManager mailboxes={mailboxes} userUid={userUid} />
541
543
  <LocalIndexLifecycle
542
544
  mailboxUid={activeMailboxUid}
543
545
  mailboxKeys={activeMailbox?.keys}
@@ -28,6 +28,7 @@ import {
28
28
  PINNED_FINGERPRINT_PAGE_SIZE,
29
29
  SignerKeyState,
30
30
  listContacts,
31
+ pinnedOpenPgpKeysFor,
31
32
  pinnedSigningFingerprintsFor,
32
33
  signerKeyStateFor,
33
34
  } from "../../../../lib/contacts/contactsApi.js";
@@ -35,6 +36,7 @@ import { keyPinnedSince } from "../contacts/contactKeys.js";
35
36
  import { listFolders } from "../../../../lib/mail/mailApi.js";
36
37
  import { subscribeKeySession } from "../../../../lib/crypto/keySession.js";
37
38
  import type { ApiClient } from "../../../../lib/util/api.js";
39
+ import type { SignerPins } from "../../../../lib/crypto/messageSecurity.js";
38
40
 
39
41
  /** How long one mailbox's loaded contact list is reused before it is fetched again. */
40
42
  export const CONTACTS_CACHE_TTL_MS = 60_000;
@@ -120,12 +122,21 @@ export async function getPinnedSignerFingerprints(mailboxUid: string, address: s
120
122
  return pinnedSigningFingerprintsFor(await cachedMailboxContacts(mailboxUid, client), address);
121
123
  }
122
124
 
125
+ /** The sender `address`'s pinned signing fingerprints and pinned OpenPGP certificates from `mailboxUid`'s contacts - what
126
+ * `evaluateMessageSecurity()` takes as `SignerPins`. Rejects if they can't be loaded. */
127
+ export async function getPinnedSigners(mailboxUid: string, address: string, client?: ApiClient): Promise<SignerPins> {
128
+ const contacts = await cachedMailboxContacts(mailboxUid, client);
129
+ return { fingerprints: pinnedSigningFingerprintsFor(contacts, address), openPgpKeys: pinnedOpenPgpKeysFor(contacts, address) };
130
+ }
131
+
123
132
  /** A sender's signing-key state (`signerKeyStateFor()`) plus what a key-changed notice needs to show and link. */
124
133
  export interface SenderKeyState extends SignerKeyState {
125
134
  /** The contact holding the first pinned signing key, else the one holding the conflict, else the first match. */
126
135
  contactUid?: string;
127
136
  /** When the first pinned signing key started being trusted - see `keyPinnedSince()`. */
128
137
  pinnedSince?: number;
138
+ /** When the first pinned OpenPGP certificate (`openPgp[0]`) started being trusted. */
139
+ openPgpPinnedSince?: number;
129
140
  }
130
141
 
131
142
  /** The sender `address`'s signing-key state from `mailboxUid`'s (cached) contacts, for a "signing key changed" comparison
@@ -142,9 +153,12 @@ export async function getSignerKeyState(mailboxUid: string, address: string, cli
142
153
  holder ??
143
154
  matching.find((candidate) => (candidate.keyConflicts ?? []).some((conflict) => conflict.useType === "sign")) ??
144
155
  matching[0];
156
+ const openPgpKey = state.openPgp?.[0];
157
+ const openPgpHolder = openPgpKey && matching.find((candidate) => (candidate.keys ?? []).includes(openPgpKey));
145
158
  return {
146
159
  ...state,
147
160
  ...(contact ? { contactUid: contact.uid } : {}),
148
161
  ...(holder ? { pinnedSince: keyPinnedSince(holder, pinned) } : {}),
162
+ ...(openPgpHolder ? { openPgpPinnedSince: keyPinnedSince(openPgpHolder, openPgpKey) } : {}),
149
163
  };
150
164
  }
@@ -39,13 +39,13 @@ export async function olderConversationMessages(message: Message, client?: ApiCl
39
39
  }
40
40
 
41
41
  /**
42
- * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
43
- * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
42
+ * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one - end to end, or sealed to its mailbox's
43
+ * zero-access key, whose subject and body the server doesn't hold readable - is decrypted here when this device has its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
44
44
  * request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
45
45
  */
46
46
  export async function printableOlderMessage(message: Message, client?: ApiClient): Promise<PrintableMessage> {
47
47
  const attachments = await listAttachments(message.folderUid, message.uid, client).catch(() => []);
48
- if (message.encrypted) {
48
+ if (message.encrypted || message.zeroAccess) {
49
49
  const unlocked = getUnlockedKeys(message.mailboxUid);
50
50
  if (unlocked) {
51
51
  const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
@@ -28,6 +28,7 @@ import { resolveFolderOfType } from "../../../mail/folderOfType.js";
28
28
  import { setReadStateMany } from "../../../mail/messageReadState.js";
29
29
  import { usePermanentDelete } from "../../../mail/usePermanentDelete.js";
30
30
  import { reportFolderName, reportNotice, reportNoun, reportTitle, senderListsHref } from "../../../mail/reportNotices.js";
31
+ import { zeroAccessSourceBase64 } from "../../../mail/zeroAccessSource.js";
31
32
  import { blockSender, neverBlockSender, normalizeAddresses, removeSenderRule } from "../../../mail/senderBlocking.js";
32
33
  import { notifyApiError } from "../../../notifications/apiErrors.js";
33
34
  import { notify } from "../../../notifications/store.js";
@@ -190,7 +191,9 @@ export function useMessageActions(params: MessageActionsParams): MessageActions
190
191
  await exclusive(async () => {
191
192
  let result: MessageReportResult;
192
193
  try {
193
- result = await reportMessage(message.uid, kind, { alwaysTrustSender }, client);
194
+ // The server holds only the sealed copy of a zero-access message: its spam filter learns from the source decrypted here.
195
+ const rawMessage: string | undefined = await zeroAccessSourceBase64(message, client);
196
+ result = await reportMessage(message.uid, kind, { alwaysTrustSender, ...(rawMessage ? { rawMessage } : {}) }, client);
194
197
  } catch (err) {
195
198
  if (isNotFound(err)) {
196
199
  await reportByMoving(kind, alwaysTrustSender);
@@ -0,0 +1,173 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useState } from "react";
6
+ import { exportKeysAsPkcs12, MIN_EXPORT_PASSPHRASE_LENGTH, NothingToExportError } from "../../../../lib/crypto/keyExport.js";
7
+ import type { Mailbox } from "../../../../lib/mail/mailApi.js";
8
+ import { ApiRequestError } from "../../../../lib/util/api.js";
9
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
+ import Alert from "../../../../lib/components/feedback/Alert.js";
11
+ import Button from "../../../../lib/components/buttons/Button.js";
12
+ import { notifyApiError } from "../../notifications/apiErrors.js";
13
+ import PolicyLockedNotice from "./PolicyLockedNotice.js";
14
+
15
+ export interface KeyExportSettingProps {
16
+ mailbox: Mailbox;
17
+ /** The caller owns the mailbox and isn't impersonating: restapi records an export only for the owner. */
18
+ canManage: boolean;
19
+ }
20
+
21
+ const INPUT_CLASS = "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface";
22
+
23
+ /** How long the file's object URL is kept, since some browsers (WebKit, older Firefox) still read it after the click returns. */
24
+ const DOWNLOAD_URL_LIFETIME_MS = 60_000;
25
+
26
+ /** Hands `bytes` to the browser as a file download. */
27
+ function saveFile(bytes: Uint8Array, filename: string): void {
28
+ const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: "application/x-pkcs12" }));
29
+ const link = document.createElement("a");
30
+ link.href = url;
31
+ link.download = filename;
32
+ document.body.appendChild(link);
33
+ link.click();
34
+ link.remove();
35
+ setTimeout(() => URL.revokeObjectURL(url), DOWNLOAD_URL_LIFETIME_MS);
36
+ }
37
+
38
+ /**
39
+ * "Export keys for Outlook/phone" (`lib/crypto/keyExport.ts`): a passphrase-protected `.p12` with the mailbox's zero-access
40
+ * and end-to-end encryption keys - and its signing key, if the user ticks the box - so a mail app that isn't RapidMX can read
41
+ * mail stored encrypted. Asks for the master password again however long the mailbox has been unlocked.
42
+ */
43
+ export default function KeyExportSetting({ mailbox, canManage }: KeyExportSettingProps) {
44
+ const client = useApiClient();
45
+ const [open, setOpen] = useState(false);
46
+ const [password, setPassword] = useState("");
47
+ const [passphrase, setPassphrase] = useState("");
48
+ const [confirmPassphrase, setConfirmPassphrase] = useState("");
49
+ const [includeSigningKey, setIncludeSigningKey] = useState(false);
50
+ const [exporting, setExporting] = useState(false);
51
+ const [error, setError] = useState<string | null>(null);
52
+
53
+ function reset() {
54
+ setOpen(false);
55
+ setPassword("");
56
+ setPassphrase("");
57
+ setConfirmPassphrase("");
58
+ setIncludeSigningKey(false);
59
+ setError(null);
60
+ }
61
+
62
+ async function handleExport(e: React.FormEvent) {
63
+ e.preventDefault();
64
+ if (passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) {
65
+ setError(`The file's passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters.`);
66
+ return;
67
+ }
68
+ if (passphrase !== confirmPassphrase) {
69
+ setError("The passphrases don't match.");
70
+ return;
71
+ }
72
+ setExporting(true);
73
+ setError(null);
74
+ try {
75
+ const result = await exportKeysAsPkcs12(
76
+ {
77
+ mailboxUid: mailbox.uid,
78
+ mailboxAddress: mailbox.primarySmtpAddress,
79
+ mailboxKeys: mailbox.keys ?? [],
80
+ masterPassword: password,
81
+ exportPassphrase: passphrase,
82
+ includeSigningKey,
83
+ },
84
+ client,
85
+ );
86
+ saveFile(result.pkcs12, `${mailbox.primarySmtpAddress}-keys.p12`);
87
+ reset();
88
+ } catch (err) {
89
+ if (err instanceof ApiRequestError) {
90
+ notifyApiError(err, "Couldn't export your keys");
91
+ } else if (err instanceof NothingToExportError || (err instanceof Error && err.message.startsWith("This mailbox has no password"))) {
92
+ setError(err.message);
93
+ } else {
94
+ setError("Incorrect password.");
95
+ }
96
+ } finally {
97
+ setExporting(false);
98
+ }
99
+ }
100
+
101
+ return (
102
+ <div>
103
+ <h2 className="text-sm font-semibold mb-2">Export keys for Outlook or a phone</h2>
104
+ <p className="text-sm text-text-muted mb-2">
105
+ Mail apps other than RapidMX need your keys to read encrypted mail. The exported file holds your zero-access and
106
+ end-to-end encryption keys, protected by a passphrase you choose. Anyone with the file and its passphrase can read
107
+ all of this mailbox&rsquo;s mail, so keep it safe and delete it once it&rsquo;s installed.
108
+ </p>
109
+ {!canManage ? (
110
+ <PolicyLockedNotice>Only this mailbox&rsquo;s owner, signed in as themselves, can export its keys.</PolicyLockedNotice>
111
+ ) : !open ? (
112
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setOpen(true)}>
113
+ Export keys&hellip;
114
+ </Button>
115
+ ) : (
116
+ <form onSubmit={(e) => void handleExport(e)} className="flex flex-col gap-2">
117
+ <input
118
+ type="password"
119
+ aria-label="Encryption password"
120
+ placeholder="Encryption password"
121
+ className={INPUT_CLASS}
122
+ value={password}
123
+ onChange={(e) => setPassword(e.target.value)}
124
+ disabled={exporting}
125
+ autoComplete="current-password"
126
+ />
127
+ <input
128
+ type="password"
129
+ aria-label="File passphrase"
130
+ placeholder={`File passphrase (at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters)`}
131
+ className={INPUT_CLASS}
132
+ value={passphrase}
133
+ onChange={(e) => setPassphrase(e.target.value)}
134
+ disabled={exporting}
135
+ autoComplete="new-password"
136
+ />
137
+ <input
138
+ type="password"
139
+ aria-label="Confirm file passphrase"
140
+ placeholder="Confirm file passphrase"
141
+ className={INPUT_CLASS}
142
+ value={confirmPassphrase}
143
+ onChange={(e) => setConfirmPassphrase(e.target.value)}
144
+ disabled={exporting}
145
+ autoComplete="new-password"
146
+ />
147
+ <label className="flex items-start gap-2 text-sm">
148
+ <input
149
+ type="checkbox"
150
+ className="mt-1"
151
+ checked={includeSigningKey}
152
+ onChange={(e) => setIncludeSigningKey(e.target.checked)}
153
+ disabled={exporting}
154
+ />
155
+ <span>
156
+ <span className="font-semibold block">Include my signing key</span>
157
+ <span className="text-xs text-text-muted">Only if that app will sign mail you send. Anyone with the file could sign as you.</span>
158
+ </span>
159
+ </label>
160
+ {error && <Alert>{error}</Alert>}
161
+ <div className="flex gap-2">
162
+ <Button type="submit" className="!w-auto" disabled={exporting || !password || !passphrase}>
163
+ {exporting ? "Exporting…" : "Export"}
164
+ </Button>
165
+ <Button type="button" variant="secondary" className="!w-auto" onClick={reset} disabled={exporting}>
166
+ Cancel
167
+ </Button>
168
+ </div>
169
+ </form>
170
+ )}
171
+ </div>
172
+ );
173
+ }