@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
@@ -213,7 +213,7 @@ function MatterDetailContent({ uid }: { uid: string }) {
213
213
  setLoadingMaterial(true);
214
214
  try {
215
215
  const material = await getAccessRequestMaterial(request.uid);
216
- if (seq === materialSeq.current) setMaterialText(JSON.stringify(material.masterKeyWraps, null, 2));
216
+ if (seq === materialSeq.current) setMaterialText(JSON.stringify(material, null, 2));
217
217
  } catch (err) {
218
218
  if (seq === materialSeq.current) {
219
219
  setMaterialError(err instanceof ApiRequestError ? err.message : "Could not read this request's material.");
@@ -631,8 +631,9 @@ function MatterDetailContent({ uid }: { uid: string }) {
631
631
  ) : (
632
632
  <>
633
633
  <p className="text-sm text-text-muted mb-3">
634
- Still-encrypted master key wraps for <strong>{materialFor?.mailboxUid}</strong> — this
635
- server cannot decrypt these either. Copy the JSON below into your own offline tooling.
634
+ Still-encrypted key material for <strong>{materialFor?.mailboxUid}</strong>: the escrow master key wrap,
635
+ and the private keys that read its mail, sealed under that master key. This server cannot decrypt any of it
636
+ either. Copy the JSON below into your own offline tooling.
636
637
  </p>
637
638
  <pre className="text-xs font-mono bg-surface-alt border border-border rounded-sm p-3 overflow-x-auto whitespace-pre-wrap break-all">
638
639
  {materialText}
@@ -5,7 +5,9 @@
5
5
  /** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
6
6
  * arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
7
7
 
8
+ import { getCalendarEvent } from "../../../lib/calendar/calendarApi.js";
8
9
  import type { PushEvent } from "../../../lib/mail/pushClient.js";
10
+ import { openSealedEvent } from "./sealedEvents.js";
9
11
 
10
12
  /** How long "Snooze" waits before the alarm comes back. */
11
13
  export const SNOOZE_MS = 5 * 60_000;
@@ -19,6 +21,39 @@ export interface CalendarReminderNotice {
19
21
  /** The event's own `location`, verbatim - `undefined`/`null` when the event has none. Not necessarily a URL;
20
22
  * see `joinMeetingUrl()` for when it's shown as a join link. */
21
23
  location?: string | null;
24
+ /** The event is of a calendar zero-access encryption protects, or was made from a zero-access invitation: `title` and `location` are
25
+ * placeholders (`openedReminder()`). */
26
+ protected?: boolean;
27
+ /** Set by `openedReminder()` on a protected notice it opened: the placeholder title the push carried, for anything recorded beyond
28
+ * this tab's memory (the notification history is kept in `sessionStorage` and outlives a mailbox lock). */
29
+ sealedTitle?: string;
30
+ /** Set with `sealedTitle`: the placeholder location the push carried, which `sealedReminder()` puts back. */
31
+ sealedLocation?: string | null;
32
+ }
33
+
34
+ /** `notice` with a protected event's real title and location, read from the event itself (opened with the mailbox's keys); `notice` as it
35
+ * came when the event can't be read or opened - the alarm still goes off, with the placeholder. Never rejects. */
36
+ export async function openedReminder(notice: CalendarReminderNotice): Promise<CalendarReminderNotice> {
37
+ if (!notice.protected) {
38
+ return notice;
39
+ }
40
+ try {
41
+ // `getCalendarEvent()` opens sealed fields; an event made from a zero-access invitation keeps its content in `sealedContent`.
42
+ const event = await openSealedEvent(await getCalendarEvent(notice.eventUid));
43
+ return { ...notice, title: event.title, location: event.location ?? null, sealedTitle: notice.title, sealedLocation: notice.location ?? null };
44
+ } catch {
45
+ return notice;
46
+ }
47
+ }
48
+
49
+ /** `notice` as the push carried it, when `openedReminder()` opened it - for an alarm still shown, or snoozed, when the mailbox locks: its real
50
+ * title and location go with the keys that opened them. `openedReminder()` opens it again (it is still `protected`). */
51
+ export function sealedReminder(notice: CalendarReminderNotice): CalendarReminderNotice {
52
+ if (notice.sealedTitle === undefined) {
53
+ return notice;
54
+ }
55
+ const { sealedTitle, sealedLocation, ...rest } = notice;
56
+ return { ...rest, title: sealedTitle, location: sealedLocation ?? null };
22
57
  }
23
58
 
24
59
  function isReminderNotice(data: unknown): data is CalendarReminderNotice {
@@ -0,0 +1,113 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Calendar events the server made from invitations that reached a zero-access mailbox keep their title, location and description only
7
+ * sealed to the mailbox's zero-access key (`CalendarEvent.sealedContent`; the row's `title` is `[...]`). These are opened here with the
8
+ * mailbox's unlocked keys so the calendar shows the real ones; while the mailbox is locked the event keeps its placeholder. The
9
+ * decryption code is loaded only when a sealed event is actually seen.
10
+ */
11
+ import type { CalendarEvent } from "../../../lib/calendar/calendarApi.js";
12
+ import { getUnlockedKeys, subscribeKeySession } from "../../../lib/crypto/keySession.js";
13
+ import { fromBase64 } from "../../../lib/crypto/encoding.js";
14
+ import { markSealedContentOpened } from "../../../lib/calendar/protectedEvents.js";
15
+ import { onSigningOut } from "../components/mail/compose/composeFlushRegistry.js";
16
+
17
+ /** What `sealedContent.sealed` decrypts to. */
18
+ interface SealedEventFields {
19
+ title?: unknown;
20
+ location?: unknown;
21
+ description?: unknown;
22
+ descriptionHtml?: unknown;
23
+ }
24
+
25
+ /** Opened fields by sealed text - the same sealed content always opens to the same fields. Cleared with `clearSealedEvents()`. */
26
+ const opened: Map<string, Partial<CalendarEvent>> = new Map();
27
+
28
+ /** Forgets every opened event (a mailbox locking, sign-out, or the test seam). */
29
+ export function clearSealedEvents(): void {
30
+ opened.clear();
31
+ }
32
+
33
+ let watchingKeys = false;
34
+
35
+ /** Opened fields go when keys are locked, so nothing opened outlives its key session. Subscribed when the first sealed event is seen,
36
+ * not on import. */
37
+ function forgetOnLock(): void {
38
+ if (watchingKeys) {
39
+ return;
40
+ }
41
+ watchingKeys = true;
42
+ subscribeKeySession((event) => {
43
+ if (event.state === "locked") {
44
+ clearSealedEvents();
45
+ }
46
+ });
47
+ }
48
+
49
+ async function openFields(event: CalendarEvent): Promise<Partial<CalendarEvent> | undefined> {
50
+ const sealed: string = event.sealedContent!.sealed;
51
+ // Checked before the cache: a locked mailbox's events show their placeholders, whatever was opened before.
52
+ const keys = getUnlockedKeys(event.mailboxUid)?.zeroAccessKeys;
53
+ if (!keys?.length) {
54
+ return undefined;
55
+ }
56
+ const known = opened.get(sealed);
57
+ if (known) {
58
+ return known;
59
+ }
60
+ try {
61
+ const { decryptEnvelopedDataWithKeys } = await import("../../../lib/crypto/smime.js");
62
+ const fields = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealed), keys))) as SealedEventFields;
63
+ const text = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
64
+ const result: Partial<CalendarEvent> = {
65
+ // An empty sealed title is no title: a newer revision without SUMMARY over a row that keeps its title sealed field by field
66
+ // (`sealedFields.title`) is sealed with an empty one, and the title opened from that copy (or the row's own) shows instead.
67
+ // Left out here, not copied from `event`, as the cache is shared by every row with this sealed content.
68
+ ...(text(fields.title) ? { title: text(fields.title) } : {}),
69
+ location: text(fields.location),
70
+ description: text(fields.description) ?? null,
71
+ descriptionHtml: text(fields.descriptionHtml) ?? null,
72
+ };
73
+ // The mailbox locked while this was opened: the lock's clear has already run, so it is neither kept nor shown.
74
+ if (!getUnlockedKeys(event.mailboxUid)) {
75
+ return undefined;
76
+ }
77
+ opened.set(sealed, result);
78
+ return result;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /** `events` with every sealed one this device can open shown with its real title, location and description (and marked opened,
85
+ * `isSealedContentOpened()`). Never rejects. */
86
+ export async function openSealedEvents(events: CalendarEvent[]): Promise<CalendarEvent[]> {
87
+ if (!events.some((event) => event.sealedContent)) {
88
+ return events;
89
+ }
90
+ forgetOnLock();
91
+ return Promise.all(
92
+ events.map(async (event) => {
93
+ if (!event.sealedContent) {
94
+ return event;
95
+ }
96
+ const fields = await openFields(event);
97
+ if (!fields) {
98
+ return event;
99
+ }
100
+ // Marked, so a reply or change request names it by its real title (`invitationContentOf()`). An empty sealed title with no
101
+ // per-field copy to show instead (an invitation without SUMMARY) is an untitled event: opened, it never shows the row's `[...]`.
102
+ const untitled = fields.title === undefined && !event.sealedFields?.title ? { title: "" } : {};
103
+ return markSealedContentOpened({ ...event, ...fields, ...untitled });
104
+ }),
105
+ );
106
+ }
107
+
108
+ /** `openSealedEvents()` for one event. */
109
+ export async function openSealedEvent(event: CalendarEvent): Promise<CalendarEvent> {
110
+ return (await openSealedEvents([event]))[0];
111
+ }
112
+
113
+ onSigningOut(clearSealedEvents);
@@ -7,7 +7,8 @@ import { getPushClient } from "../../../lib/mail/pushClient.js";
7
7
  import { getNotificationsEnabled } from "../notifications/preferences.js";
8
8
  import { playNotificationSound } from "../notifications/sounds.js";
9
9
  import { notify } from "../notifications/store.js";
10
- import { CalendarReminderNotice, SNOOZE_MS, calendarReminderOf, reminderMessage, reminderNotificationId } from "./calendarReminders.js";
10
+ import { useKeysLocked } from "../zeroAccess/useKeysLocked.js";
11
+ import { CalendarReminderNotice, SNOOZE_MS, calendarReminderOf, openedReminder, reminderMessage, reminderNotificationId, sealedReminder } from "./calendarReminders.js";
11
12
 
12
13
  export interface UseCalendarRemindersOptions {
13
14
  /** Nothing is shown without a signed-in user. */
@@ -40,19 +41,26 @@ export interface CalendarAlarms {
40
41
  * with the bell again - after exactly `SNOOZE_MS`. A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the alarm itself (the
41
42
  * push event is never replayed), it does not survive the tab closing.
42
43
  *
43
- * With pop-ups turned off (`getNotificationsEnabled()`) no dialog is shown and nothing is heard: the alarm is only recorded in "Recent notifications".
44
+ * With pop-ups turned off (`getNotificationsEnabled()`) no dialog is shown and nothing is heard: the alarm is only recorded in "Recent notifications"
45
+ * - a protected event's under its placeholder title, never the opened one (the history is kept in `sessionStorage`, past a lock).
44
46
  */
45
47
  export function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions): CalendarAlarms {
46
48
  const [queue, setQueue] = useState<CalendarReminderNotice[]>([]);
47
49
  // The queue as of the last render, for the callbacks below to read (a state updater must not start a timer: React may run it twice).
48
50
  const queueRef = useRef(queue);
49
51
  queueRef.current = queue;
50
- const timers = useRef(new Set<ReturnType<typeof setTimeout>>());
52
+ // Each snoozed alarm by its timer: what it shows when it comes back (a lock meanwhile puts back its placeholders).
53
+ const timers = useRef(new Map<ReturnType<typeof setTimeout>, CalendarReminderNotice>());
51
54
 
52
55
  const present = useCallback((notice: CalendarReminderNotice) => {
53
56
  if (!getNotificationsEnabled()) {
54
- // Off: only the history keeps it.
55
- notify({ id: reminderNotificationId(notice.eventUid, notice.startDate), kind: "calendar", title: notice.title, message: reminderMessage(notice.startDate) });
57
+ // Off: only the history keeps it - with a protected event's placeholder title, since the history is stored in sessionStorage.
58
+ notify({
59
+ id: reminderNotificationId(notice.eventUid, notice.startDate),
60
+ kind: "calendar",
61
+ title: notice.sealedTitle ?? notice.title,
62
+ message: reminderMessage(notice.startDate),
63
+ });
56
64
  return;
57
65
  }
58
66
  const id = reminderNotificationId(notice.eventUid, notice.startDate);
@@ -67,13 +75,13 @@ export function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersO
67
75
  const offEvent = getPushClient().onEvent((event) => {
68
76
  const notice = calendarReminderOf(event);
69
77
  if (notice) {
70
- present(notice);
78
+ void openedReminder(notice).then(present);
71
79
  }
72
80
  });
73
81
  const pending = timers.current;
74
82
  return () => {
75
83
  offEvent();
76
- for (const timer of pending) {
84
+ for (const timer of pending.keys()) {
77
85
  clearTimeout(timer);
78
86
  }
79
87
  pending.clear();
@@ -81,15 +89,26 @@ export function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersO
81
89
  };
82
90
  }, [enabled, userUid, present]);
83
91
 
92
+ // A protected event's alarm shows its title and location opened with the mailbox's keys: on a lock - a notice doesn't say which mailbox,
93
+ // so any - the alarms shown or snoozed go back to the placeholders the push carried.
94
+ useKeysLocked(() => {
95
+ setQueue((current) => current.map(sealedReminder));
96
+ for (const [timer, notice] of timers.current) {
97
+ timers.current.set(timer, sealedReminder(notice));
98
+ }
99
+ });
100
+
84
101
  const dismiss = useCallback(() => setQueue((current) => current.slice(1)), []);
85
102
  const snooze = useCallback(() => {
86
103
  const first = queueRef.current[0];
87
104
  if (first) {
88
105
  const timer = setTimeout(() => {
106
+ const notice = timers.current.get(timer)!;
89
107
  timers.current.delete(timer);
90
- present(first);
108
+ // Put back to its placeholders by a lock: opened again if the mailbox is unlocked now.
109
+ void (notice.protected && notice.sealedTitle === undefined ? openedReminder(notice) : Promise.resolve(notice)).then(present);
91
110
  }, SNOOZE_MS);
92
- timers.current.add(timer);
111
+ timers.current.set(timer, first);
93
112
  }
94
113
  setQueue((current) => current.slice(1));
95
114
  }, [present]);
@@ -15,6 +15,7 @@ const RECORD_TYPE_LABELS: Record<DnsRecordCheck["type"], string> = {
15
15
  spf: "SPF",
16
16
  dkim: "DKIM",
17
17
  dmarc: "DMARC",
18
+ openpgpkey_cname: "OpenPGP keys (CNAME)",
18
19
  autodiscover_cname: "Autodiscover (CNAME)",
19
20
  autodiscover_srv: "Autodiscover (SRV)",
20
21
  };
@@ -26,6 +27,7 @@ const RECORD_TYPE_NAMES: Record<DnsRecordCheck["type"], string> = {
26
27
  spf: "SPF",
27
28
  dkim: "DKIM",
28
29
  dmarc: "DMARC",
30
+ openpgpkey_cname: "OpenPGP keys CNAME",
29
31
  autodiscover_cname: "Autodiscover CNAME",
30
32
  autodiscover_srv: "Autodiscover SRV",
31
33
  };
@@ -33,6 +35,8 @@ const RECORD_TYPE_NAMES: Record<DnsRecordCheck["type"], string> = {
33
35
  /** A one-line "why this matters", shown under a checklist row's label - only for record types unfamiliar
34
36
  * enough to need one; the well-known mail records (ownership/MX/SPF/DKIM/DMARC) don't. */
35
37
  const RECORD_TYPE_HELP: Partial<Record<DnsRecordCheck["type"], string>> = {
38
+ openpgpkey_cname:
39
+ "So Proton Mail and other OpenPGP apps can find your users' OpenPGP keys (Web Key Directory) and send them encrypted mail. This hostname also needs to be covered by this server's TLS certificate.",
36
40
  autodiscover_cname:
37
41
  "So EAS and Outlook clients can find this server automatically from just an email address. This hostname also needs to be covered by this server's TLS certificate.",
38
42
  autodiscover_srv:
@@ -12,7 +12,9 @@ import { actionErrorMessage } from "../elevation.js";
12
12
  const SELECT_CLASS =
13
13
  "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
14
14
 
15
- const TIERS: { key: keyof EncryptionPolicy; label: string; help: string }[] = [
15
+ type TierKey = "encryptSameOrg" | "encryptFederated" | "encryptExternal";
16
+
17
+ const TIERS: { key: TierKey; label: string; help: string }[] = [
16
18
  { key: "encryptSameOrg", label: "Mail within this server", help: "Messages between mailboxes hosted on this server." },
17
19
  {
18
20
  key: "encryptFederated",
@@ -52,11 +54,20 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
52
54
 
53
55
  // Edits are unsaved until they match what was loaded or last saved.
54
56
  const [savedValues, setSavedValues] = useState<EncryptionPolicy>(policy);
55
- const dirty: boolean = TIERS.some((tier) => values[tier.key] !== savedValues[tier.key]);
57
+ // A server without zero-access encryption sends no `allowZeroAccess`; the checkbox is then not shown at all.
58
+ const zeroAccessSupported: boolean = policy.allowZeroAccess !== undefined;
59
+ // Likewise `allowOpenPgp`, from a server without OpenPGP support.
60
+ const openPgpSupported: boolean = policy.allowOpenPgp !== undefined;
61
+ const dirty: boolean =
62
+ TIERS.some((tier) => values[tier.key] !== savedValues[tier.key]) ||
63
+ (zeroAccessSupported && values.allowZeroAccess !== savedValues.allowZeroAccess) ||
64
+ (openPgpSupported && values.allowOpenPgp !== savedValues.allowOpenPgp);
56
65
  useEffect(() => onDirtyChange?.(dirty), [dirty]);
57
66
 
58
67
  // Tiers this save would newly set to "Never encrypt", which switches encryption off for everyone's mail of that kind: confirmed first.
59
68
  const [confirmingProhibit, setConfirmingProhibit] = useState<string[] | null>(null);
69
+ // Turning zero-access encryption off makes new mail readable by this server again for every mailbox: confirmed first.
70
+ const [confirmingZeroAccessOff, setConfirmingZeroAccessOff] = useState(false);
60
71
 
61
72
  function handleSubmit(e: FormEvent) {
62
73
  e.preventDefault();
@@ -65,11 +76,21 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
65
76
  setConfirmingProhibit(newlyProhibited.map((tier) => tier.label));
66
77
  return;
67
78
  }
79
+ confirmZeroAccessThenSave();
80
+ }
81
+
82
+ function confirmZeroAccessThenSave() {
83
+ setConfirmingProhibit(null);
84
+ if (zeroAccessSupported && values.allowZeroAccess === false && savedValues.allowZeroAccess !== false) {
85
+ setConfirmingZeroAccessOff(true);
86
+ return;
87
+ }
68
88
  void save();
69
89
  }
70
90
 
71
91
  async function save() {
72
92
  setConfirmingProhibit(null);
93
+ setConfirmingZeroAccessOff(false);
73
94
  setError(null);
74
95
  setSaved(false);
75
96
  setSaving(true);
@@ -122,6 +143,55 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
122
143
  <span className="text-xs text-text-muted">{tier.help}</span>
123
144
  </label>
124
145
  ))}
146
+ {zeroAccessSupported && (
147
+ <div className="mt-2 pt-4 border-t border-border">
148
+ <span className="font-semibold text-sm block mb-1">Zero-access encryption</span>
149
+ <label className="flex items-start gap-2 text-sm">
150
+ <input
151
+ type="checkbox"
152
+ className="mt-1"
153
+ checked={values.allowZeroAccess !== false}
154
+ onChange={(e) => {
155
+ setSaved(false);
156
+ setValues({ ...values, allowZeroAccess: e.target.checked });
157
+ }}
158
+ />
159
+ <span>
160
+ <span className="font-semibold block">Allow zero-access encryption</span>
161
+ <span className="text-xs text-text-muted">
162
+ Mail that arrives unencrypted is encrypted the moment it is received, to a key only the mailbox&rsquo;s
163
+ owner can unlock, and this server keeps no readable copy - administrators can&rsquo;t read it either,
164
+ except through an escrow scope. People can still turn it off for their own mailbox. Search of message
165
+ contents then happens in the app instead of on the server.
166
+ </span>
167
+ </span>
168
+ </label>
169
+ </div>
170
+ )}
171
+ {openPgpSupported && (
172
+ <div className="mt-2 pt-4 border-t border-border">
173
+ <span className="font-semibold text-sm block mb-1">OpenPGP</span>
174
+ <label className="flex items-start gap-2 text-sm">
175
+ <input
176
+ type="checkbox"
177
+ className="mt-1"
178
+ checked={values.allowOpenPgp !== false}
179
+ onChange={(e) => {
180
+ setSaved(false);
181
+ setValues({ ...values, allowOpenPgp: e.target.checked });
182
+ }}
183
+ />
184
+ <span>
185
+ <span className="font-semibold block">Allow OpenPGP</span>
186
+ <span className="text-xs text-text-muted">
187
+ Gives every mailbox an OpenPGP key and publishes it, so people using Proton Mail, Thunderbird and other
188
+ OpenPGP apps can send encrypted mail to your users, and your users to them. RapidMX still uses S/MIME
189
+ whenever a recipient can receive it. Off, no OpenPGP keys are created or published.
190
+ </span>
191
+ </span>
192
+ </label>
193
+ </div>
194
+ )}
125
195
  <div>
126
196
  <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
127
197
  Save
@@ -138,11 +208,27 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
138
208
  <Button type="button" variant="secondary" className="!w-auto" onClick={() => setConfirmingProhibit(null)}>
139
209
  Cancel
140
210
  </Button>
141
- <Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={() => void save()}>
211
+ <Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={confirmZeroAccessThenSave}>
142
212
  Save and turn off encryption
143
213
  </Button>
144
214
  </div>
145
215
  </Modal>
216
+
217
+ <Modal open={confirmingZeroAccessOff} onClose={() => setConfirmingZeroAccessOff(false)} title="Turn off zero-access encryption?">
218
+ <p className="text-sm mb-3">
219
+ Mail that arrives from now on will be stored in a form this server can read, for every mailbox. Mail that is already
220
+ encrypted stays encrypted - this server can&rsquo;t decrypt it. Everyone sees in their settings that an administrator
221
+ turned it off.
222
+ </p>
223
+ <div className="flex gap-3 justify-end">
224
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setConfirmingZeroAccessOff(false)}>
225
+ Cancel
226
+ </Button>
227
+ <Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={() => void save()}>
228
+ Save and turn off zero-access encryption
229
+ </Button>
230
+ </div>
231
+ </Modal>
146
232
  </div>
147
233
  );
148
234
  }
@@ -18,10 +18,13 @@ import {
18
18
  import { ApiRequestError } from "../../../../lib/util/api.js";
19
19
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
20
20
  import { AttendeeResponseInput, guestPermissionsOf, respondToEvent, visibilityOf } from "../../../../lib/calendar/calendarApi.js";
21
+ import { invitationContentOf } from "../../../../lib/calendar/protectedEvents.js";
22
+ import { organizerJoinLink } from "../../../../lib/videoconf/protectedMeetings.js";
21
23
  import { htmlToPlainText } from "../../../../lib/calendar/eventDescription.js";
22
24
  import { deleteEventOccurrence, deleteEventSeries } from "../../../../lib/calendar/calendarMutations.js";
23
25
  import { CalendarOccurrence, describeRecurrence } from "../../../../lib/calendar/recurrence.js";
24
26
  import { getVideoMeeting } from "../../../../lib/videoconf/videoMeetingsApi.js";
27
+ import { useKeysLocked } from "../../zeroAccess/useKeysLocked.js";
25
28
  import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.js";
26
29
  import Alert from "../../../../lib/components/feedback/Alert.js";
27
30
  import Button from "../../../../lib/components/buttons/Button.js";
@@ -109,7 +112,7 @@ export default function EventDetails({
109
112
  }
110
113
  let cancelled = false;
111
114
  void getVideoMeeting(videoMeetingUid, client)
112
- .then((meeting) => meeting.organizerJoinUrl ?? null)
115
+ .then(organizerJoinLink)
113
116
  .catch(() => null)
114
117
  .then((url) => {
115
118
  if (!cancelled) {
@@ -120,6 +123,13 @@ export default function EventDetails({
120
123
  cancelled = true;
121
124
  };
122
125
  }, [videoMeetingUid, client]);
126
+ // A protected meeting's link carries its key after `#`, opened with the mailbox's keys: it goes when they lock, whether or not the
127
+ // event itself is protected (a meeting is keyed whenever the mailbox has zero-access, its calendar protected or not).
128
+ useKeysLocked((locked) => {
129
+ if (locked === occurrence.mailboxUid) {
130
+ setOrganizerJoinUrl((url) => (url?.includes("#") ? null : url));
131
+ }
132
+ });
123
133
  // The same scheme allow-list a reminder's own location gets: a hostile or malformed join link never reaches `window.open()`.
124
134
  const joinUrl = joinMeetingUrl(organizerJoinUrl);
125
135
 
@@ -146,7 +156,7 @@ export default function EventDetails({
146
156
  setError(null);
147
157
  setBusy(true);
148
158
  try {
149
- await respondToEvent(occurrence.uid, responseStatus, client);
159
+ await respondToEvent(occurrence.uid, responseStatus, client, occurrence);
150
160
  // A decline soft-deletes the mailbox's own copy server-side - treat it the same as a
151
161
  // delete rather than a save so the calendar view drops it immediately.
152
162
  if (responseStatus === "declined") {
@@ -356,6 +366,7 @@ export default function EventDetails({
356
366
  description: occurrence.description,
357
367
  descriptionHtml: occurrence.descriptionHtml,
358
368
  guestAddresses: occurrence.attendees.map((a) => a.address),
369
+ invitationContent: invitationContentOf(occurrence),
359
370
  }}
360
371
  canChange={canChange}
361
372
  canInvite={canInvite}