@rapidmx/web-client 0.36.0 → 0.37.1

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 (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -0,0 +1,240 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useState } from "react";
6
+ import { type EncryptionPreference, type EndToEndFormat, findActivePublicKey } from "../../../../lib/crypto/keyvaultApi.js";
7
+ import { MIN_EXPORT_PASSPHRASE_LENGTH } from "../../../../lib/crypto/keyExport.js";
8
+ import { exportOpenPgpSecretKey } from "../../../../lib/crypto/openPgpKey.js";
9
+ import { getMailbox, type Mailbox, updateMailbox } from "../../../../lib/mail/mailApi.js";
10
+ import { ApiRequestError } from "../../../../lib/util/api.js";
11
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
12
+ import Alert from "../../../../lib/components/feedback/Alert.js";
13
+ import Button from "../../../../lib/components/buttons/Button.js";
14
+ import { notifyApiError } from "../../notifications/apiErrors.js";
15
+ import PolicyLockedNotice from "./PolicyLockedNotice.js";
16
+ import { useSettingsShell } from "./layout/SettingsShell.js";
17
+
18
+ export interface OpenPgpKeySettingProps {
19
+ mailbox: Mailbox;
20
+ /** The caller owns the mailbox and isn't impersonating: only the owner may export its secret key. */
21
+ canManage: boolean;
22
+ }
23
+
24
+ const INPUT_CLASS = "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface";
25
+
26
+ /** How long a download's object URL is kept, since some browsers (WebKit, older Firefox) still read it after the click returns. */
27
+ const DOWNLOAD_URL_LIFETIME_MS = 60_000;
28
+
29
+ /** Hands `text` to the browser as a file download. */
30
+ function saveText(text: string, filename: string): void {
31
+ const url = URL.createObjectURL(new Blob([text], { type: "application/pgp-keys" }));
32
+ const link = document.createElement("a");
33
+ link.href = url;
34
+ link.download = filename;
35
+ document.body.appendChild(link);
36
+ link.click();
37
+ link.remove();
38
+ setTimeout(() => URL.revokeObjectURL(url), DOWNLOAD_URL_LIFETIME_MS);
39
+ }
40
+
41
+ /** `preference` advertising `format` - S/MIME is the default, so it is advertised by saying nothing. */
42
+ function withPreferredFormat(preference: EncryptionPreference | undefined, format: EndToEndFormat): EncryptionPreference {
43
+ const { preferFormat: _previous, ...rest } = preference ?? { preferEncrypt: "nopreference" as const };
44
+ return format === "openpgp" ? { ...rest, preferFormat: "openpgp" } : rest;
45
+ }
46
+
47
+ /** A fingerprint in groups of four, for reading aloud. */
48
+ function groupFingerprint(fingerprint: string): string {
49
+ return fingerprint.toUpperCase().replace(/(.{4})/g, "$1 ").trim();
50
+ }
51
+
52
+ /**
53
+ * The mailbox's OpenPGP key (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): what it is for, its
54
+ * fingerprint, a download of its public key (to give a correspondent by hand), and an export of its secret key, protected by a
55
+ * passphrase, for reading this mailbox's OpenPGP mail in another app. Shows nothing while the mailbox has no OpenPGP key.
56
+ */
57
+ export default function OpenPgpKeySetting({ mailbox, canManage }: OpenPgpKeySettingProps) {
58
+ const client = useApiClient();
59
+ const { patchMailbox } = useSettingsShell();
60
+ const [open, setOpen] = useState(false);
61
+ const [password, setPassword] = useState("");
62
+ const [passphrase, setPassphrase] = useState("");
63
+ const [confirmPassphrase, setConfirmPassphrase] = useState("");
64
+ const [exporting, setExporting] = useState(false);
65
+ const [error, setError] = useState<string | null>(null);
66
+ const [format, setFormat] = useState<EndToEndFormat>(mailbox.encryptPreference?.preferFormat === "openpgp" ? "openpgp" : "smime");
67
+ const [savingFormat, setSavingFormat] = useState(false);
68
+ const key = findActivePublicKey(mailbox.keys ?? [], "encrypt", "openpgp");
69
+ if (!key) {
70
+ return null;
71
+ }
72
+ const address = mailbox.primarySmtpAddress;
73
+
74
+ function reset() {
75
+ setOpen(false);
76
+ setPassword("");
77
+ setPassphrase("");
78
+ setConfirmPassphrase("");
79
+ setError(null);
80
+ }
81
+
82
+ /** Advertises `next` as the format this mailbox would rather receive (restapi *Choosing the format*), to RapidMX servers through
83
+ * key discovery and the `RapidMX-Key` header. */
84
+ async function saveFormat(next: EndToEndFormat) {
85
+ const previous = format;
86
+ setFormat(next);
87
+ setSavingFormat(true);
88
+ try {
89
+ const write = async (current: Pick<Mailbox, "version" | "encryptPreference">): Promise<Pick<Mailbox, "version" | "encryptPreference">> => {
90
+ const encryptPreference: EncryptionPreference = withPreferredFormat(current.encryptPreference, next);
91
+ const saved = await updateMailbox({ uid: mailbox.uid, version: current.version, encryptPreference }, client);
92
+ return { version: saved.version, encryptPreference: saved.encryptPreference ?? encryptPreference };
93
+ };
94
+ let saved: Pick<Mailbox, "version" | "encryptPreference">;
95
+ try {
96
+ saved = await write(mailbox);
97
+ } catch (err) {
98
+ // Enrolling a key moves the mailbox's version on: read it again and retry once.
99
+ if (!(err instanceof ApiRequestError && err.status === 409)) {
100
+ throw err;
101
+ }
102
+ saved = await write(await getMailbox(mailbox.uid, {}, client));
103
+ }
104
+ // Kept in the settings shell's copy of the mailbox, which this page is mounted from again on a switch back to it.
105
+ patchMailbox?.(mailbox.uid, saved);
106
+ } catch (err) {
107
+ setFormat(previous);
108
+ notifyApiError(err, "Couldn't save your preferred encryption format");
109
+ } finally {
110
+ setSavingFormat(false);
111
+ }
112
+ }
113
+
114
+ async function downloadPublicKey() {
115
+ const { armorPublicKey, readPublicKey } = await import("../../../../lib/crypto/openpgp.js");
116
+ const certificate = await readPublicKey(key!.publicKey);
117
+ if (certificate) {
118
+ saveText(armorPublicKey(certificate), `${address}-openpgp-public.asc`);
119
+ }
120
+ }
121
+
122
+ async function handleExport(e: React.FormEvent) {
123
+ e.preventDefault();
124
+ if (passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) {
125
+ setError(`The file's passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters.`);
126
+ return;
127
+ }
128
+ if (passphrase !== confirmPassphrase) {
129
+ setError("The passphrases don't match.");
130
+ return;
131
+ }
132
+ setExporting(true);
133
+ setError(null);
134
+ try {
135
+ const { armored } = await exportOpenPgpSecretKey(mailbox.uid, password, passphrase, client);
136
+ saveText(armored, `${address}-openpgp-secret.asc`);
137
+ reset();
138
+ } catch (err) {
139
+ if (err instanceof ApiRequestError) {
140
+ notifyApiError(err, "Couldn't export your OpenPGP key");
141
+ } else {
142
+ setError("Incorrect password.");
143
+ }
144
+ } finally {
145
+ setExporting(false);
146
+ }
147
+ }
148
+
149
+ return (
150
+ <div>
151
+ <h2 className="text-sm font-semibold mb-2">OpenPGP key</h2>
152
+ <p className="text-sm text-text-muted mb-2">
153
+ People who use Proton Mail, Thunderbird or another OpenPGP app can send you encrypted mail with this key, and you can
154
+ send it to them. RapidMX publishes it for you; you can also give it to someone yourself. RapidMX uses S/MIME whenever a
155
+ recipient can receive it.
156
+ </p>
157
+ <p className="text-sm mb-2">
158
+ Fingerprint: <code className="text-xs break-all">{groupFingerprint(key.fingerprint)}</code>
159
+ </p>
160
+ <fieldset className="mb-3" disabled={!canManage || savingFormat}>
161
+ <legend className="text-sm font-semibold mb-1">Preferred format</legend>
162
+ <p className="text-xs text-text-muted mb-1">
163
+ What other RapidMX servers use to encrypt to you when they could use either. Choose OpenPGP if you mostly read this
164
+ mailbox in an OpenPGP app.
165
+ </p>
166
+ {(
167
+ [
168
+ ["smime", "S/MIME (recommended)"],
169
+ ["openpgp", "OpenPGP"],
170
+ ] as const
171
+ ).map(([value, label]) => (
172
+ <label key={value} className="flex items-center gap-2 text-sm">
173
+ <input type="radio" name={`preferred-format-${mailbox.uid}`} checked={format === value} onChange={() => void saveFormat(value)} />
174
+ {label}
175
+ </label>
176
+ ))}
177
+ </fieldset>
178
+ <div className="flex flex-wrap gap-2 mb-2">
179
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => void downloadPublicKey()}>
180
+ Download public key
181
+ </Button>
182
+ {canManage && !open && (
183
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setOpen(true)}>
184
+ Export secret key&hellip;
185
+ </Button>
186
+ )}
187
+ </div>
188
+ {!canManage ? (
189
+ <PolicyLockedNotice>Only this mailbox&rsquo;s owner, signed in as themselves, can export its secret key.</PolicyLockedNotice>
190
+ ) : (
191
+ open && (
192
+ <form onSubmit={(e) => void handleExport(e)} className="flex flex-col gap-2">
193
+ <p className="text-sm text-text-muted">
194
+ Anyone with the exported file and its passphrase can read this mailbox&rsquo;s OpenPGP mail and sign as you.
195
+ </p>
196
+ <input
197
+ type="password"
198
+ aria-label="Encryption password"
199
+ placeholder="Encryption password"
200
+ className={INPUT_CLASS}
201
+ value={password}
202
+ onChange={(e) => setPassword(e.target.value)}
203
+ disabled={exporting}
204
+ autoComplete="current-password"
205
+ />
206
+ <input
207
+ type="password"
208
+ aria-label="Key passphrase"
209
+ placeholder={`Key passphrase (at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters)`}
210
+ className={INPUT_CLASS}
211
+ value={passphrase}
212
+ onChange={(e) => setPassphrase(e.target.value)}
213
+ disabled={exporting}
214
+ autoComplete="new-password"
215
+ />
216
+ <input
217
+ type="password"
218
+ aria-label="Confirm key passphrase"
219
+ placeholder="Confirm key passphrase"
220
+ className={INPUT_CLASS}
221
+ value={confirmPassphrase}
222
+ onChange={(e) => setConfirmPassphrase(e.target.value)}
223
+ disabled={exporting}
224
+ autoComplete="new-password"
225
+ />
226
+ {error && <Alert>{error}</Alert>}
227
+ <div className="flex gap-2">
228
+ <Button type="submit" className="!w-auto" loading={exporting} disabled={exporting || !password}>
229
+ Export
230
+ </Button>
231
+ <Button type="button" variant="secondary" className="!w-auto" onClick={reset} disabled={exporting}>
232
+ Cancel
233
+ </Button>
234
+ </div>
235
+ </form>
236
+ )
237
+ )}
238
+ </div>
239
+ );
240
+ }
@@ -8,7 +8,12 @@ import { getMailbox, type Mailbox, updateMailbox } from "../../../../lib/mail/ma
8
8
  import { ApiRequestError } from "../../../../lib/util/api.js";
9
9
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
10
  import { notifyApiError } from "../../notifications/apiErrors.js";
11
+ import { useSettingsShell } from "./layout/SettingsShell.js";
11
12
  import PolicyLockedNotice from "./PolicyLockedNotice.js";
13
+ import ZeroAccessTypes from "./ZeroAccessTypes.js";
14
+
15
+ /** The data types this client can protect with zero-access encryption - each joins as its protection is implemented end to end. */
16
+ export const ZERO_ACCESS_SUPPORTED_TYPES = ["mail", "calendar", "contacts", "tasks"] as const;
12
17
 
13
18
  /** Why zero-access encryption can't be changed here, when it can't - mirrors restapi's `ZeroAccessStatus` reasons that the user can't fix
14
19
  * by ticking the box. */
@@ -28,6 +33,7 @@ export interface ZeroAccessSettingProps {
28
33
  */
29
34
  export default function ZeroAccessSetting({ mailbox, canManage }: ZeroAccessSettingProps) {
30
35
  const client = useApiClient();
36
+ const { patchMailbox } = useSettingsShell();
31
37
  const [enabled, setEnabled] = useState(mailbox.zeroAccessEnabled !== false);
32
38
  const [blocker, setBlocker] = useState<Blocker | null | undefined>(undefined);
33
39
  const [saving, setSaving] = useState(false);
@@ -57,16 +63,20 @@ export default function ZeroAccessSetting({ mailbox, canManage }: ZeroAccessSett
57
63
  setEnabled(next);
58
64
  try {
59
65
  const write = (version: number) => updateMailbox({ uid: mailbox.uid, version, zeroAccessEnabled: next }, client);
66
+ let saved: Mailbox;
60
67
  try {
61
- await write(mailbox.version);
68
+ saved = await write(mailbox.version);
62
69
  } catch (err) {
63
70
  // Enrolling a key (which happens on unlock) moves the mailbox's version on, so the copy this page was given may be
64
71
  // behind: read it again and retry once.
65
72
  if (!(err instanceof ApiRequestError && err.status === 409)) {
66
73
  throw err;
67
74
  }
68
- await write((await getMailbox(mailbox.uid, {}, client)).version);
75
+ saved = await write((await getMailbox(mailbox.uid, {}, client)).version);
69
76
  }
77
+ // The server's answer: turning it on decides every type the owner left undecided (restapi `settleZeroAccessTypes()`), which
78
+ // "What to protect" then shows.
79
+ patchMailbox?.(mailbox.uid, { zeroAccessEnabled: next, zeroAccessTypes: saved.zeroAccessTypes ?? mailbox.zeroAccessTypes, version: saved.version });
70
80
  } catch (err) {
71
81
  setEnabled(previous);
72
82
  notifyApiError(err, "Couldn't save the zero-access encryption setting");
@@ -97,6 +107,7 @@ export default function ZeroAccessSetting({ mailbox, canManage }: ZeroAccessSett
97
107
  </span>
98
108
  </span>
99
109
  </label>
110
+ {!locked && enabled && <ZeroAccessTypes mailbox={mailbox} types={ZERO_ACCESS_SUPPORTED_TYPES} canManage={canManage} disabled={saving} />}
100
111
  {blocker === "disabled_by_admin" && (
101
112
  <PolicyLockedNotice>
102
113
  Your administrator has turned off zero-access encryption for this server, so new mail is stored without it. Mail
@@ -0,0 +1,175 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useState } from "react";
6
+ import { getMailbox, type Mailbox, updateMailbox, type ZeroAccessDataType, type ZeroAccessTypes as ChosenTypes } from "../../../../lib/mail/mailApi.js";
7
+ import { ApiRequestError } from "../../../../lib/util/api.js";
8
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
9
+ import { notifyApiError } from "../../notifications/apiErrors.js";
10
+ import { useSettingsShell } from "./layout/SettingsShell.js";
11
+
12
+ /** What each data type's protection covers, what stays readable and why, and what it breaks on a phone or Outlook - the copy restapi
13
+ * `specs/search.md` §3 *What It Covers* requires settings to state plainly. */
14
+ export const ZERO_ACCESS_TYPE_INFO: Record<ZeroAccessDataType, { label: string; encrypted: string; readable: string; onDevices: string }> = {
15
+ mail: {
16
+ label: "Mail",
17
+ encrypted: "Message bodies, subjects, previews and attachments",
18
+ readable: "Sender, recipients, dates, size, flags and folder - to deliver, thread and search your mail",
19
+ onDevices: "can't open protected mail until you install your exported keys on it (Export keys below)",
20
+ },
21
+ calendar: {
22
+ label: "Calendar",
23
+ encrypted: "Event titles, locations and descriptions",
24
+ readable: "Times, repeats, time zone, attendees, responses and busy status - for free/busy, invitations and reminders",
25
+ onDevices:
26
+ "shows protected events only as busy blocks named “Protected event”, and attendees aren't told when it changes a meeting (a phone) or sends one (Outlook) - send those from RapidMX",
27
+ },
28
+ contacts: {
29
+ label: "Contacts",
30
+ encrypted: "Names, company, job title, phone numbers, postal addresses, notes, categories and photos",
31
+ readable: "Email addresses - the server needs them to deliver mail and to find each contact's encryption keys",
32
+ onDevices: "shows protected contacts by their email address only",
33
+ },
34
+ tasks: {
35
+ label: "Tasks",
36
+ encrypted: "Task titles and notes",
37
+ readable: "Due dates, reminders, priority, completion and list",
38
+ onDevices: "shows protected tasks as “Protected task”",
39
+ },
40
+ };
41
+
42
+ /** Whether `type` is included: the owner's choice, else (undecided) mail on and the rest off - restapi's `zeroAccessTypeIncluded()`. */
43
+ export function typeIncluded(types: ChosenTypes | undefined, type: ZeroAccessDataType): boolean {
44
+ const chosen = types?.[type];
45
+ return typeof chosen === "boolean" ? chosen : type === "mail";
46
+ }
47
+
48
+ /** The names of the owner's ActiveSync devices that sync `type`, and Outlook when it syncs the mailbox. */
49
+ export function devicesSyncing(mailbox: Pick<Mailbox, "syncClients">, type: ZeroAccessDataType): string[] {
50
+ const names = (mailbox.syncClients?.activesync?.devices ?? []).filter((device) => device.types.includes(type)).map((device) => device.model || "A phone");
51
+ return [...new Set(names), ...(mailbox.syncClients?.mapi ? ["Outlook"] : [])];
52
+ }
53
+
54
+ export interface ZeroAccessTypesProps {
55
+ mailbox: Mailbox;
56
+ /** The data types this client can protect, in order - each one's switch appears once its protection is implemented. */
57
+ types: readonly ZeroAccessDataType[];
58
+ /** The owner, not impersonating: only they change it. */
59
+ canManage: boolean;
60
+ /** Zero-access is being turned on: the server is still deciding the undecided types, so nothing is changed before it has. */
61
+ disabled?: boolean;
62
+ }
63
+
64
+ /**
65
+ * "What to protect": one switch per data type zero-access encryption covers, each saying what it encrypts and - for a type one of the
66
+ * owner's ActiveSync devices or Outlook syncs - plainly what that device will no longer show (restapi `specs/search.md` §3 *What It
67
+ * Covers*). Then the table of what stays readable to the server and why.
68
+ */
69
+ export default function ZeroAccessTypes({ mailbox, types, canManage, disabled = false }: ZeroAccessTypesProps) {
70
+ const client = useApiClient();
71
+ const { patchMailbox } = useSettingsShell();
72
+ const [chosen, setChosen] = useState<ChosenTypes>(mailbox.zeroAccessTypes ?? {});
73
+ const [saving, setSaving] = useState(false);
74
+ // What the server saved, once the mailbox has it (turning zero-access on decides the undecided types there): shown instead of what this
75
+ // was mounted with - set while rendering, so the stale switches are never drawn.
76
+ const storedTypes = JSON.stringify(mailbox.zeroAccessTypes ?? {});
77
+ const [shownTypes, setShownTypes] = useState(storedTypes);
78
+ if (shownTypes !== storedTypes) {
79
+ setShownTypes(storedTypes);
80
+ setChosen(JSON.parse(storedTypes) as ChosenTypes);
81
+ }
82
+
83
+ async function save(type: ZeroAccessDataType, on: boolean) {
84
+ const previous = chosen;
85
+ const next = { ...chosen, [type]: on };
86
+ setChosen(next);
87
+ setSaving(true);
88
+ try {
89
+ const write = (version: number) => updateMailbox({ uid: mailbox.uid, version, zeroAccessTypes: { [type]: on } }, client);
90
+ let saved: Mailbox;
91
+ try {
92
+ saved = await write(mailbox.version);
93
+ } catch (err) {
94
+ // The server records devices on the mailbox, moving its version on: read it again and retry once.
95
+ if (!(err instanceof ApiRequestError && err.status === 409)) {
96
+ throw err;
97
+ }
98
+ saved = await write((await getMailbox(mailbox.uid, {}, client)).version);
99
+ }
100
+ // Kept by the shell, so turning zero-access off and on again (which mounts this anew) shows what was saved: the server's merge of
101
+ // this choice into the stored one.
102
+ const merged = saved.zeroAccessTypes ?? next;
103
+ setChosen(merged);
104
+ patchMailbox?.(mailbox.uid, { zeroAccessTypes: merged, version: saved.version });
105
+ } catch (err) {
106
+ setChosen(previous);
107
+ notifyApiError(err, "Couldn't save what zero-access encryption protects");
108
+ } finally {
109
+ setSaving(false);
110
+ }
111
+ }
112
+
113
+ return (
114
+ <div className="mt-3">
115
+ <h3 className="text-sm font-semibold mb-1">What to protect</h3>
116
+ <ul className="flex flex-col gap-2">
117
+ {types.map((type) => {
118
+ const info = ZERO_ACCESS_TYPE_INFO[type];
119
+ const devices = devicesSyncing(mailbox, type);
120
+ const on = typeIncluded(chosen, type);
121
+ const keyInstalled = type === "mail" && !!mailbox.zeroAccessKeysExportedAt;
122
+ return (
123
+ <li key={type}>
124
+ <label className="flex items-start gap-2 text-sm">
125
+ <input
126
+ type="checkbox"
127
+ className="mt-1"
128
+ checked={on}
129
+ disabled={!canManage || saving || disabled}
130
+ onChange={(e) => void save(type, e.target.checked)}
131
+ />
132
+ <span>
133
+ <span className="font-semibold block">{info.label}</span>
134
+ <span className="text-xs text-text-muted block">{info.encrypted}.</span>
135
+ {devices.length > 0 && !keyInstalled && (
136
+ <span role={on ? "alert" : undefined} className={`text-xs block ${on ? "text-danger" : "text-text-muted"}`}>
137
+ {on ? "Breaks: " : "Left off because "}
138
+ {devices.join(", ")} {devices.length === 1 ? "syncs" : "sync"} this - {on ? "it " : "protected, it "}
139
+ {info.onDevices}.
140
+ </span>
141
+ )}
142
+ </span>
143
+ </label>
144
+ </li>
145
+ );
146
+ })}
147
+ </ul>
148
+ <details className="mt-3 text-sm">
149
+ <summary className="cursor-pointer font-semibold">What&rsquo;s encrypted, and what isn&rsquo;t</summary>
150
+ <table className="mt-2 w-full text-xs border-collapse">
151
+ <thead>
152
+ <tr className="text-left">
153
+ <th className="py-1 pr-2">Type</th>
154
+ <th className="py-1 pr-2">Encrypted</th>
155
+ <th className="py-1">Readable by the server, and why</th>
156
+ </tr>
157
+ </thead>
158
+ <tbody>
159
+ {types.map((type) => (
160
+ <tr key={type} className="border-t border-border align-top">
161
+ <td className="py-1 pr-2 font-semibold">{ZERO_ACCESS_TYPE_INFO[type].label}</td>
162
+ <td className="py-1 pr-2">{typeIncluded(chosen, type) ? ZERO_ACCESS_TYPE_INFO[type].encrypted : "Nothing - not protected"}</td>
163
+ <td className="py-1">{ZERO_ACCESS_TYPE_INFO[type].readable}</td>
164
+ </tr>
165
+ ))}
166
+ </tbody>
167
+ </table>
168
+ <p className="mt-2 text-xs text-text-muted">
169
+ For every type, the server can also see folder and list names, how many items there are and when they change. Zero-access
170
+ encryption protects what is stored: the server sees what you save while it saves it, then keeps only the encrypted copy.
171
+ </p>
172
+ </details>
173
+ </div>
174
+ );
175
+ }
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
5
+ import React, { createContext, PropsWithChildren, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
6
6
  import type { IconType } from "react-icons";
7
7
  import {
8
8
  HiOutlineArrowUturnLeft,
@@ -82,6 +82,9 @@ export interface SettingsShellContextValue {
82
82
  * every settings section so far (Automatic Replies) edits mailbox-scoped fields. */
83
83
  mailboxUid?: string;
84
84
  mailboxes: Mailbox[];
85
+ /** Merges what a section just saved of a mailbox (its new `version` included) into the shell's copy, so a section mounted again for
86
+ * it - after the mailbox switcher, say - starts from what was saved rather than from the list loaded once. */
87
+ patchMailbox?: (mailboxUid: string, patch: Partial<Mailbox>) => void;
85
88
  }
86
89
 
87
90
  const SettingsShellContext = createContext<SettingsShellContextValue>({ mailboxes: [] });
@@ -151,7 +154,11 @@ export default function SettingsShell({
151
154
  (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
152
155
  primaryMailboxUid(mailboxes, userUid);
153
156
 
154
- const contextValue = useMemo<SettingsShellContextValue>(() => ({ mailboxUid, mailboxes }), [mailboxUid, mailboxes]);
157
+ const patchMailbox = useCallback(
158
+ (uid: string, patch: Partial<Mailbox>) => setMailboxes((list) => list.map((mb) => (mb.uid === uid ? { ...mb, ...patch } : mb))),
159
+ [],
160
+ );
161
+ const contextValue = useMemo<SettingsShellContextValue>(() => ({ mailboxUid, mailboxes, patchMailbox }), [mailboxUid, mailboxes, patchMailbox]);
155
162
 
156
163
  // A full-screen takeover, not nested inside the rest of the app's chrome — same as ContactsShell/
157
164
  // TasksShell, there's nothing else for a mailbox-less caller to configure here yet.