@rapidmx/web-client 0.35.0 → 0.36.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 (119) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/sealedEvents.ts +80 -0
  3. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
  4. package/apps/shared/components/contacts/contactCardData.ts +2 -1
  5. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +23 -1
  6. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +91 -0
  7. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  8. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  9. package/apps/shared/components/mail/MessageDetailPane.tsx +35 -13
  10. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  11. package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
  12. package/apps/shared/components/mail/invite/inviteStore.ts +7 -1
  13. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  14. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  15. package/apps/shared/components/settings/KeyExportSetting.tsx +170 -0
  16. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  17. package/apps/shared/components/settings/ZeroAccessSetting.tsx +117 -0
  18. package/apps/shared/mail/decryptedMessages.ts +11 -3
  19. package/apps/shared/mail/outbox/sendJob.ts +2 -2
  20. package/apps/shared/mail/reportNotices.ts +3 -1
  21. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  22. package/apps/shared/search/LocalIndexLifecycle.tsx +31 -0
  23. package/apps/shared/search/localIndexBuilder.ts +27 -1
  24. package/apps/shared/search/localIndexRpcClient.ts +8 -4
  25. package/apps/shared/search/localIndexSchema.ts +2 -2
  26. package/apps/shared/search/localIndexTransport.ts +2 -1
  27. package/apps/shared/search/localIndexWorker.ts +6 -4
  28. package/apps/shared/search/zeroAccessIndexSync.ts +487 -0
  29. package/apps/www/calendar/index.tsx +176 -56
  30. package/apps/www/index.tsx +10 -3
  31. package/apps/www/settings/encryption/index.tsx +5 -0
  32. package/apps/www/settings/privacy/index.tsx +2 -1
  33. package/dist/apps/escrow/matters/[uid].js +2 -2
  34. package/dist/apps/shared/calendar/sealedEvents.d.ts +13 -0
  35. package/dist/apps/shared/calendar/sealedEvents.js +54 -0
  36. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +18 -2
  37. package/dist/apps/shared/components/contacts/contactCardData.js +2 -1
  38. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +21 -2
  39. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +15 -0
  40. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +72 -0
  41. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  42. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  43. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  44. package/dist/apps/shared/components/mail/MessageDetailPane.js +34 -13
  45. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  46. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  47. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
  48. package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
  49. package/dist/apps/shared/components/mail/invite/inviteStore.js +6 -1
  50. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  51. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  52. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  53. package/dist/apps/shared/components/settings/KeyExportSetting.js +88 -0
  54. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  55. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  56. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +14 -0
  57. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +71 -0
  58. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  59. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  60. package/dist/apps/shared/mail/outbox/sendJob.js +2 -2
  61. package/dist/apps/shared/mail/reportNotices.js +3 -1
  62. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  63. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  64. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  65. package/dist/apps/shared/search/LocalIndexLifecycle.js +28 -0
  66. package/dist/apps/shared/search/localIndexBuilder.d.ts +5 -0
  67. package/dist/apps/shared/search/localIndexBuilder.js +24 -2
  68. package/dist/apps/shared/search/localIndexRpcClient.d.ts +1 -1
  69. package/dist/apps/shared/search/localIndexRpcClient.js +6 -4
  70. package/dist/apps/shared/search/localIndexSchema.d.ts +1 -1
  71. package/dist/apps/shared/search/localIndexSchema.js +2 -2
  72. package/dist/apps/shared/search/localIndexTransport.d.ts +2 -1
  73. package/dist/apps/shared/search/localIndexWorker.d.ts +2 -0
  74. package/dist/apps/shared/search/localIndexWorker.js +4 -4
  75. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +71 -0
  76. package/dist/apps/shared/search/zeroAccessIndexSync.js +382 -0
  77. package/dist/apps/www/calendar/index.js +41 -15
  78. package/dist/apps/www/index.js +10 -3
  79. package/dist/apps/www/settings/encryption/index.js +3 -1
  80. package/dist/apps/www/settings/privacy/index.js +1 -1
  81. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  82. package/dist/lib/calendar/calendarApi.d.ts +6 -0
  83. package/dist/lib/calendar/inviteApi.js +27 -5
  84. package/dist/lib/calendar/sealedInvites.d.ts +11 -0
  85. package/dist/lib/calendar/sealedInvites.js +28 -0
  86. package/dist/lib/crypto/keyExport.d.ts +54 -0
  87. package/dist/lib/crypto/keyExport.js +161 -0
  88. package/dist/lib/crypto/keySession.d.ts +45 -1
  89. package/dist/lib/crypto/keySession.js +93 -2
  90. package/dist/lib/crypto/keyvaultApi.d.ts +88 -3
  91. package/dist/lib/crypto/keyvaultApi.js +32 -0
  92. package/dist/lib/crypto/messageSecurity.d.ts +30 -1
  93. package/dist/lib/crypto/messageSecurity.js +80 -4
  94. package/dist/lib/crypto/mime.d.ts +5 -0
  95. package/dist/lib/crypto/mime.js +25 -0
  96. package/dist/lib/crypto/sharedMailboxKeys.d.ts +50 -0
  97. package/dist/lib/crypto/sharedMailboxKeys.js +264 -0
  98. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  99. package/dist/lib/crypto/zeroAccessKey.js +66 -0
  100. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  101. package/dist/lib/mail/mailApi.d.ts +30 -2
  102. package/dist/lib/mail/mailApi.js +5 -1
  103. package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
  104. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  105. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  106. package/lib/calendar/calendarApi.ts +3 -0
  107. package/lib/calendar/inviteApi.ts +28 -5
  108. package/lib/calendar/sealedInvites.ts +33 -0
  109. package/lib/crypto/keyExport.ts +229 -0
  110. package/lib/crypto/keySession.ts +117 -3
  111. package/lib/crypto/keyvaultApi.ts +125 -3
  112. package/lib/crypto/messageSecurity.ts +110 -3
  113. package/lib/crypto/mime.ts +26 -0
  114. package/lib/crypto/sharedMailboxKeys.ts +324 -0
  115. package/lib/crypto/zeroAccessKey.ts +97 -0
  116. package/lib/mail/conversationsApi.ts +3 -0
  117. package/lib/mail/mailApi.ts +33 -3
  118. package/lib/search/zeroAccessIndexApi.ts +119 -0
  119. package/package.json +3 -3
@@ -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}
@@ -0,0 +1,80 @@
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 } from "../../../lib/crypto/keySession.js";
13
+ import { fromBase64 } from "../../../lib/crypto/encoding.js";
14
+ import { onSigningOut } from "../components/mail/compose/composeFlushRegistry.js";
15
+
16
+ /** What `sealedContent.sealed` decrypts to. */
17
+ interface SealedEventFields {
18
+ title?: unknown;
19
+ location?: unknown;
20
+ description?: unknown;
21
+ descriptionHtml?: unknown;
22
+ }
23
+
24
+ /** Opened fields by sealed text - the same sealed content always opens to the same fields. Cleared with `clearSealedEvents()`. */
25
+ const opened: Map<string, Partial<CalendarEvent>> = new Map();
26
+
27
+ /** Forgets every opened event (sign-out, or the test seam). */
28
+ export function clearSealedEvents(): void {
29
+ opened.clear();
30
+ }
31
+
32
+ async function openFields(event: CalendarEvent): Promise<Partial<CalendarEvent> | undefined> {
33
+ const sealed: string = event.sealedContent!.sealed;
34
+ const known = opened.get(sealed);
35
+ if (known) {
36
+ return known;
37
+ }
38
+ const keys = getUnlockedKeys(event.mailboxUid)?.zeroAccessKeys;
39
+ if (!keys?.length) {
40
+ return undefined;
41
+ }
42
+ try {
43
+ const { decryptEnvelopedDataWithKeys } = await import("../../../lib/crypto/smime.js");
44
+ const fields = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealed), keys))) as SealedEventFields;
45
+ const text = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
46
+ const result: Partial<CalendarEvent> = {
47
+ title: text(fields.title) ?? event.title,
48
+ location: text(fields.location),
49
+ description: text(fields.description) ?? null,
50
+ descriptionHtml: text(fields.descriptionHtml) ?? null,
51
+ };
52
+ opened.set(sealed, result);
53
+ return result;
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ /** `events` with every sealed one this device can open shown with its real title, location and description. Never rejects. */
60
+ export async function openSealedEvents(events: CalendarEvent[]): Promise<CalendarEvent[]> {
61
+ if (!events.some((event) => event.sealedContent)) {
62
+ return events;
63
+ }
64
+ return Promise.all(
65
+ events.map(async (event) => {
66
+ if (!event.sealedContent) {
67
+ return event;
68
+ }
69
+ const fields = await openFields(event);
70
+ return fields ? { ...event, ...fields } : event;
71
+ }),
72
+ );
73
+ }
74
+
75
+ /** `openSealedEvents()` for one event. */
76
+ export async function openSealedEvent(event: CalendarEvent): Promise<CalendarEvent> {
77
+ return (await openSealedEvents([event]))[0];
78
+ }
79
+
80
+ onSigningOut(clearSealedEvents);
@@ -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,16 @@ 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
+ const dirty: boolean =
60
+ TIERS.some((tier) => values[tier.key] !== savedValues[tier.key]) || (zeroAccessSupported && values.allowZeroAccess !== savedValues.allowZeroAccess);
56
61
  useEffect(() => onDirtyChange?.(dirty), [dirty]);
57
62
 
58
63
  // Tiers this save would newly set to "Never encrypt", which switches encryption off for everyone's mail of that kind: confirmed first.
59
64
  const [confirmingProhibit, setConfirmingProhibit] = useState<string[] | null>(null);
65
+ // Turning zero-access encryption off makes new mail readable by this server again for every mailbox: confirmed first.
66
+ const [confirmingZeroAccessOff, setConfirmingZeroAccessOff] = useState(false);
60
67
 
61
68
  function handleSubmit(e: FormEvent) {
62
69
  e.preventDefault();
@@ -65,11 +72,21 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
65
72
  setConfirmingProhibit(newlyProhibited.map((tier) => tier.label));
66
73
  return;
67
74
  }
75
+ confirmZeroAccessThenSave();
76
+ }
77
+
78
+ function confirmZeroAccessThenSave() {
79
+ setConfirmingProhibit(null);
80
+ if (zeroAccessSupported && values.allowZeroAccess === false && savedValues.allowZeroAccess !== false) {
81
+ setConfirmingZeroAccessOff(true);
82
+ return;
83
+ }
68
84
  void save();
69
85
  }
70
86
 
71
87
  async function save() {
72
88
  setConfirmingProhibit(null);
89
+ setConfirmingZeroAccessOff(false);
73
90
  setError(null);
74
91
  setSaved(false);
75
92
  setSaving(true);
@@ -122,6 +139,31 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
122
139
  <span className="text-xs text-text-muted">{tier.help}</span>
123
140
  </label>
124
141
  ))}
142
+ {zeroAccessSupported && (
143
+ <div className="mt-2 pt-4 border-t border-border">
144
+ <span className="font-semibold text-sm block mb-1">Zero-access encryption</span>
145
+ <label className="flex items-start gap-2 text-sm">
146
+ <input
147
+ type="checkbox"
148
+ className="mt-1"
149
+ checked={values.allowZeroAccess !== false}
150
+ onChange={(e) => {
151
+ setSaved(false);
152
+ setValues({ ...values, allowZeroAccess: e.target.checked });
153
+ }}
154
+ />
155
+ <span>
156
+ <span className="font-semibold block">Allow zero-access encryption</span>
157
+ <span className="text-xs text-text-muted">
158
+ Mail that arrives unencrypted is encrypted the moment it is received, to a key only the mailbox&rsquo;s
159
+ owner can unlock, and this server keeps no readable copy - administrators can&rsquo;t read it either,
160
+ except through an escrow scope. People can still turn it off for their own mailbox. Search of message
161
+ contents then happens in the app instead of on the server.
162
+ </span>
163
+ </span>
164
+ </label>
165
+ </div>
166
+ )}
125
167
  <div>
126
168
  <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
127
169
  Save
@@ -138,11 +180,27 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
138
180
  <Button type="button" variant="secondary" className="!w-auto" onClick={() => setConfirmingProhibit(null)}>
139
181
  Cancel
140
182
  </Button>
141
- <Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={() => void save()}>
183
+ <Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={confirmZeroAccessThenSave}>
142
184
  Save and turn off encryption
143
185
  </Button>
144
186
  </div>
145
187
  </Modal>
188
+
189
+ <Modal open={confirmingZeroAccessOff} onClose={() => setConfirmingZeroAccessOff(false)} title="Turn off zero-access encryption?">
190
+ <p className="text-sm mb-3">
191
+ Mail that arrives from now on will be stored in a form this server can read, for every mailbox. Mail that is already
192
+ encrypted stays encrypted - this server can&rsquo;t decrypt it. Everyone sees in their settings that an administrator
193
+ turned it off.
194
+ </p>
195
+ <div className="flex gap-3 justify-end">
196
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setConfirmingZeroAccessOff(false)}>
197
+ Cancel
198
+ </Button>
199
+ <Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={() => void save()}>
200
+ Save and turn off zero-access encryption
201
+ </Button>
202
+ </div>
203
+ </Modal>
146
204
  </div>
147
205
  );
148
206
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { addDays } from "date-fns";
11
11
  import { CalendarEvent, listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
12
+ import { openSealedEvents } from "../../calendar/sealedEvents.js";
12
13
  import { type CalendarOccurrence, expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
13
14
  import { type Contact, createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
14
15
  import { type ParsedVCardContact, parseVCards } from "../../../../lib/contacts/vcard.js";
@@ -90,7 +91,7 @@ export async function loadRecentMessages(mailboxes: readonly Mailbox[], address:
90
91
  export async function loadUpcomingEvents(folders: readonly Folder[], address: string, now: Date, client?: ApiClient): Promise<CalendarOccurrence[]> {
91
92
  const wanted = normalizeAddress(address);
92
93
  const calendars = folders.filter((folder) => folder.type === "calendar");
93
- const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client)));
94
+ const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client).then(openSealedEvents)));
94
95
  const involved = lists
95
96
  .flat()
96
97
  .filter(
@@ -20,9 +20,11 @@ import {
20
20
  import {
21
21
  ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE,
22
22
  getUnlockedKeys,
23
+ subscribeKeySession,
23
24
  unlockWithPassword,
24
25
  type UnlockResult,
25
26
  } from "../../../../lib/crypto/keySession.js";
27
+ import { ensureZeroAccessKey, provisionZeroAccessKey } from "../../../../lib/crypto/zeroAccessKey.js";
26
28
  import { buildAad, generateMasterKey, sealWithKey } from "../../../../lib/crypto/masterKey.js";
27
29
  import { buildPasswordWrap, buildRecoveryWraps } from "../../../../lib/crypto/masterKeyWraps.js";
28
30
  import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "../../../../lib/crypto/keys.js";
@@ -46,13 +48,17 @@ async function provisionEncryptionKey(
46
48
  const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
47
49
  const wrappedKeySealed = await sealWithKey(mk, privateKeyRaw, buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE));
48
50
 
49
- await enrollKey(mailboxUid, {
51
+ const vault = await enrollKey(mailboxUid, {
50
52
  useType: "encrypt",
51
53
  csr: csrPem,
52
54
  wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
53
55
  masterKeyWraps: [passwordWrap, ...recoveryWraps],
54
56
  });
55
57
 
58
+ // The zero-access key (restapi specs/search.md §3), under the same master key. Not fatal: setup is done once the
59
+ // encryption key is in, and a mailbox without a zero-access key gets one on its next unlock (`ensureZeroAccessKey()`).
60
+ await provisionZeroAccessKey(mailboxUid, mailboxAddress, mk, vault.masterKeyGeneration).catch(() => undefined);
61
+
56
62
  return { recoveryCodes };
57
63
  }
58
64
 
@@ -154,6 +160,22 @@ export default function KeyEnrollmentGate({
154
160
  };
155
161
  }, [mailboxUid, canProvision]);
156
162
 
163
+ // A mailbox set up before zero-access encryption existed has no zero-access key: give it one whenever its owner has
164
+ // it unlocked - now, or on a later unlock from anywhere (this gate, an unlock prompt, recovery). Best effort: a
165
+ // failure is retried on the next unlock.
166
+ useEffect(() => {
167
+ if (!mailboxUid || !mailboxAddress || !canProvision) {
168
+ return;
169
+ }
170
+ const ensure = () => void ensureZeroAccessKey(mailboxUid, mailboxAddress).catch(() => undefined);
171
+ ensure();
172
+ return subscribeKeySession((event) => {
173
+ if (event.mailboxUid === mailboxUid && event.state === "unlocked") {
174
+ ensure();
175
+ }
176
+ });
177
+ }, [mailboxUid, mailboxAddress, canProvision]);
178
+
157
179
  async function handleUnlock(e: React.FormEvent) {
158
180
  e.preventDefault();
159
181
  setError(null);
@@ -0,0 +1,91 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useRef } from "react";
6
+ import { getUnlockedKeys, personallyUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
7
+ import type { Mailbox } from "../../../../lib/mail/mailApi.js";
8
+ import type { ApiClient } from "../../../../lib/util/api.js";
9
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
+
11
+ /** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
12
+ export const SHARED_MAILBOX_CHECK_INTERVAL_MS = 5 * 60 * 1000;
13
+
14
+ /** The shared mailbox key code (pkijs and friends), loaded only once an own mailbox is unlocked. */
15
+ let loaded: Promise<typeof import("../../../../lib/crypto/sharedMailboxKeys.js")> | undefined;
16
+ const loadSharedKeys = () =>
17
+ (loaded ??= import("../../../../lib/crypto/sharedMailboxKeys.js").catch((err) => {
18
+ loaded = undefined;
19
+ throw err;
20
+ }));
21
+
22
+ export interface SharedMailboxKeyManagerProps {
23
+ /** Every mailbox the user can open. */
24
+ mailboxes: Mailbox[];
25
+ userUid?: string;
26
+ }
27
+
28
+ /**
29
+ * Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
30
+ * unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
31
+ * password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
32
+ * silent: a pass that fails is tried again on the next. Renders nothing; mounted by `MailShell`.
33
+ */
34
+ export default function SharedMailboxKeyManager({ mailboxes, userUid }: SharedMailboxKeyManagerProps) {
35
+ const client = useApiClient();
36
+ const latest = useRef({ mailboxes, userUid, client });
37
+ latest.current = { mailboxes, userUid, client };
38
+ const running = useRef(false);
39
+
40
+ useEffect(() => {
41
+ async function pass(): Promise<void> {
42
+ if (running.current || personallyUnlockedKeys().length === 0) {
43
+ return;
44
+ }
45
+ running.current = true;
46
+ try {
47
+ const { maintainSharedMailbox, unlockSharedMailbox } = await loadSharedKeys();
48
+ const { mailboxes: current, userUid: me, client: apiClient } = latest.current;
49
+ for (const mailbox of current) {
50
+ await look(mailbox, me, apiClient, unlockSharedMailbox, maintainSharedMailbox).catch(() => undefined);
51
+ }
52
+ } catch {
53
+ // Nothing to do until the next pass.
54
+ } finally {
55
+ running.current = false;
56
+ }
57
+ }
58
+ void pass();
59
+ const unsubscribe = subscribeKeySession((event) => {
60
+ if (event.state === "unlocked" && !getUnlockedKeys(event.mailboxUid)?.shared) {
61
+ void pass();
62
+ }
63
+ });
64
+ const interval = setInterval(() => void pass(), SHARED_MAILBOX_CHECK_INTERVAL_MS);
65
+ return () => {
66
+ unsubscribe();
67
+ clearInterval(interval);
68
+ };
69
+ }, []);
70
+
71
+ return null;
72
+ }
73
+
74
+ async function look(
75
+ mailbox: Mailbox,
76
+ me: string | undefined,
77
+ client: ApiClient | undefined,
78
+ unlockSharedMailbox: (uid: string, client?: ApiClient) => Promise<string>,
79
+ maintainSharedMailbox: (uid: string, address: string, client?: ApiClient) => Promise<unknown>,
80
+ ): Promise<void> {
81
+ const owned = !!mailbox.ownerUserUid && mailbox.ownerUserUid === me;
82
+ if (!owned && !getUnlockedKeys(mailbox.uid)) {
83
+ await unlockSharedMailbox(mailbox.uid, client);
84
+ }
85
+ const unlocked = getUnlockedKeys(mailbox.uid);
86
+ // An owner looks after their mailbox with its own keys; a member, with full access, whether or not it is open yet (a mailbox
87
+ // without an owner may have no key to open).
88
+ if (owned ? unlocked && !unlocked.shared : true) {
89
+ await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
90
+ }
91
+ }
@@ -15,6 +15,7 @@ import Modal from "../../../../lib/components/overlays/Modal.js";
15
15
  import Alert from "../../../../lib/components/feedback/Alert.js";
16
16
  import Button from "../../../../lib/components/buttons/Button.js";
17
17
  import FormField from "../../../../lib/components/forms/FormField.js";
18
+ import { notify } from "../../notifications/store.js";
18
19
  import {
19
20
  RECOVERY_CODE_ERROR,
20
21
  RecoveryFollowUp,
@@ -98,6 +99,34 @@ const UnlockPromptContext = createContext<UnlockPromptContextValue | null>(null)
98
99
  * mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
99
100
  * `false` for exactly this reason.
100
101
  */
102
+ /** Messages for a shared mailbox `openShared()` can't open. */
103
+ export const SHARED_ACCESS_PENDING_MESSAGE = "This mailbox is shared with you, and is waiting for another member to share its key with you. Try again shortly.";
104
+ export const SHARED_OWN_KEYS_LOCKED_MESSAGE = "Unlock your own mailbox first: it holds the key that opens the mailboxes shared with you.";
105
+
106
+ /**
107
+ * Opens `mailboxUid` through the user's member key wraps when it is a mailbox shared with them (`sharedMailboxKeys.ts`): its keys, or
108
+ * `undefined` when it isn't one of theirs (the password prompt applies). Rejects, after saying why, when it is shared with them but can't
109
+ * open yet. The shared key code is loaded only here.
110
+ */
111
+ async function openShared(mailboxUid: string): Promise<UnlockedKeys | undefined> {
112
+ let outcome: string;
113
+ try {
114
+ const { unlockSharedMailbox } = await import("../../../../lib/crypto/sharedMailboxKeys.js");
115
+ outcome = await unlockSharedMailbox(mailboxUid);
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ if (outcome === "unlocked") {
120
+ return getUnlockedKeys(mailboxUid);
121
+ }
122
+ if (outcome === "pending" || outcome === "own_keys_locked") {
123
+ const message = outcome === "pending" ? SHARED_ACCESS_PENDING_MESSAGE : SHARED_OWN_KEYS_LOCKED_MESSAGE;
124
+ notify({ kind: "info", title: "Shared mailbox", message });
125
+ throw new Error(message);
126
+ }
127
+ return undefined;
128
+ }
129
+
101
130
  export function UnlockPromptProvider({ children }: { children: React.ReactNode }) {
102
131
  const [pending, setPendingState] = useState<PendingUnlock | null>(null);
103
132
  // Mirrors `pending` synchronously, so two requests issued in the same tick see each other.
@@ -122,6 +151,11 @@ export function UnlockPromptProvider({ children }: { children: React.ReactNode }
122
151
  if (existing) {
123
152
  return Promise.resolve(existing);
124
153
  }
154
+ // A mailbox shared with the user opens through their member key wraps, not a password - and its password, if it has one, isn't theirs.
155
+ return openShared(mailboxUid).then((shared) => shared ?? promptForPassword(mailboxUid, mailboxKeys));
156
+ }, []);
157
+
158
+ const promptForPassword = useCallback((mailboxUid: string, mailboxKeys: PublicKey[]): Promise<UnlockedKeys> => {
125
159
  return new Promise<UnlockedKeys>((resolve, reject) => {
126
160
  const current = pendingRef.current;
127
161
  if (current?.mailboxUid === mailboxUid) {
@@ -125,7 +125,7 @@ export default function ConversationList({
125
125
  let stale = false;
126
126
  void mapWithConcurrency(conversations.filter(conversationLooksEncrypted), 4, async (conversation) => {
127
127
  if (!stale) {
128
- await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
128
+ await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid, conversation.latestZeroAccess?.sealedSummary);
129
129
  }
130
130
  });
131
131
  return () => {
@@ -48,6 +48,7 @@ import MoveToFolderDialog from "./MoveToFolderDialog.js";
48
48
  import InviteCard from "./InviteCard.js";
49
49
  import { isCalendarAttachment } from "./invite/inviteFormat.js";
50
50
  import { useMessageInvite } from "./invite/inviteStore.js";
51
+ import { rememberSealedInvite } from "../../../../lib/calendar/sealedInvites.js";
51
52
  import { getUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
52
53
  import type { MessageSecurityResult, SignatureFailureReason } from "../../../../lib/crypto/messageSecurity.js";
53
54
  import { extractAddresses, type MimeAttachment } from "../../../../lib/crypto/mime.js";
@@ -116,6 +117,9 @@ const SECURITY_INDICATOR: Record<MessageSecurityResult["state"], { label: string
116
117
  // Verified when first opened (a verification seal), but not live: muted, never the green verified pill. A signer key
117
118
  // later reported compromised switches it to `LATER_COMPROMISED_INDICATOR_CLASS`.
118
119
  verified_at_first_open: { label: "Verified when first opened", className: "bg-surface-alt text-text-muted" },
120
+ // Sealed by the server on arrival (zero-access): distinct from "Encrypted" - the server held this message in the clear while it
121
+ // delivered it - and never worded as end-to-end, per the spec's indicator table.
122
+ protected_at_rest: { label: "Protected at rest", className: "bg-primary/5 text-text" },
119
123
  };
120
124
 
121
125
  /** The amber `verified_at_first_open` badge for a signer key since reported compromised. */
@@ -289,11 +293,14 @@ function IconAction({
289
293
  );
290
294
  }
291
295
 
292
- function SecurityIndicator({ security }: { security: MessageSecurityResult }) {
293
- const { label, className } = SECURITY_INDICATOR[security.state];
296
+ function SecurityIndicator({ security, zeroAccess }: { security: MessageSecurityResult; zeroAccess?: boolean }) {
297
+ // A zero-access message this device can't open yet (keys locked) evaluates as an encrypted one it has no key for: it is still
298
+ // labelled for what it is.
299
+ const state = zeroAccess && security.state === "encrypted" && security.decryptError !== undefined ? "protected_at_rest" : security.state;
300
+ const { label, className } = SECURITY_INDICATOR[state];
294
301
  if (security.state !== "verified_at_first_open") {
295
302
  // Every encrypted state carries a small lock, so the badge reads at a glance; the words are unchanged.
296
- const encrypted = security.state.startsWith("encrypted");
303
+ const encrypted = state.startsWith("encrypted") || state === "protected_at_rest";
297
304
  return (
298
305
  <span className={`inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`}>
299
306
  {encrypted && <HiOutlineLockClosed size={12} aria-hidden="true" />}
@@ -473,7 +480,7 @@ export default function MessageDetailPane(props: MessageDetailPaneProps) {
473
480
  * surfaced as an attachment - so a message with neither flag can't carry a signature to verify, and
474
481
  * fetching its full raw source (attachments included) would be pure waste. */
475
482
  function needsRawSecurityEvaluation(message: Message): boolean {
476
- return !!message.encrypted || message.hasAttachments;
483
+ return !!message.encrypted || !!message.zeroAccess || message.hasAttachments;
477
484
  }
478
485
 
479
486
  function MessageDetailContent({
@@ -686,14 +693,14 @@ function MessageDetailContent({
686
693
  .catch(() => {
687
694
  if (!cancelled) {
688
695
  setSecurity(
689
- message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
696
+ message.encrypted || message.zeroAccess ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
690
697
  );
691
698
  }
692
699
  });
693
700
  return () => {
694
701
  cancelled = true;
695
702
  };
696
- }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
703
+ }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, message.zeroAccess?.keyFingerprint, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
697
704
 
698
705
  // Whether the body is the locked state right now (an encrypted message this device holds no unlocked keys for), for the subscription below.
699
706
  const lockedRef = useRef(false);
@@ -1078,7 +1085,11 @@ function MessageDetailContent({
1078
1085
  // listed with a warning. A signed-only failure never carries recovered attachments.
1079
1086
  const innerAttachments =
1080
1087
  security?.attachments !== undefined &&
1081
- (verifiedOrSealed || security.state === "encrypted" || security.state === "encrypted_unverified_signer" || security.state === "signature_failed")
1088
+ (verifiedOrSealed ||
1089
+ security.zeroAccess ||
1090
+ security.state === "encrypted" ||
1091
+ security.state === "encrypted_unverified_signer" ||
1092
+ security.state === "signature_failed")
1082
1093
  ? security.attachments
1083
1094
  : undefined;
1084
1095
  // Any state with a valid signature shows the address actually signed for (the protected From when the message
@@ -1132,7 +1143,18 @@ function MessageDetailContent({
1132
1143
  // Only a message the server can read and that carries a calendar file is worth asking about an invitation. An encrypted message is out: the
1133
1144
  // server holds only its ciphertext (the calendar file is inside it), so there is nothing for it to find. Drafts and Outbox hold messages
1134
1145
  // that are being written or sent, not received invitations.
1135
- const mayHoldInvite = !message.encrypted && !inOutbox && message.folderUid !== draftsFolderUid && attachments.some(isCalendarAttachment);
1146
+ // A zero-access message's calendar file is inside the sealed copy: the server reads the invitation from the one decrypted here.
1147
+ const sealedCalendar: string | undefined = security?.zeroAccess ? security.calendar : undefined;
1148
+ // Registered while rendering, not in an effect: the invitation lookup below must find it the moment it is enabled. Writing the same
1149
+ // file for the same message again is a no-op.
1150
+ if (sealedCalendar !== undefined) {
1151
+ rememberSealedInvite(message.uid, sealedCalendar);
1152
+ }
1153
+ const mayHoldInvite =
1154
+ !message.encrypted &&
1155
+ !inOutbox &&
1156
+ message.folderUid !== draftsFolderUid &&
1157
+ (attachments.some(isCalendarAttachment) || sealedCalendar !== undefined);
1136
1158
  // The calendar file of a message whose invitation card is drawn is the card, not a chip in the attachment list (a nameless "attachment" to click):
1137
1159
  // it is listed again if the card could not be drawn (no invitation the server can read, or the lookup failed), so the file is never out of reach.
1138
1160
  const { invite: shownInvite } = useMessageInvite(message.uid, mayHoldInvite);
@@ -1247,7 +1269,7 @@ function MessageDetailContent({
1247
1269
  /** The rules page's "New mail filter", started from this message: its sender and (for a message the server can read) its subject. */
1248
1270
  function handleCreateRule() {
1249
1271
  const params = new URLSearchParams({ mailboxUid: message.mailboxUid, from: message.from.address });
1250
- const subject = message.encrypted ? "" : withoutSubjectPrefixes(message.subject);
1272
+ const subject = message.encrypted || message.zeroAccess ? "" : withoutSubjectPrefixes(message.subject);
1251
1273
  if (subject) {
1252
1274
  params.set("subject", subject);
1253
1275
  }
@@ -1273,7 +1295,7 @@ function MessageDetailContent({
1273
1295
  createRule: handleCreateRule,
1274
1296
  };
1275
1297
  // An encrypted message can be printed once it has been opened here: while it is still opening, or when it can't be, there is nothing to print.
1276
- const printReason = !message.encrypted
1298
+ const printReason = !message.encrypted && !message.zeroAccess
1277
1299
  ? undefined
1278
1300
  : security === null
1279
1301
  ? "Still opening this message"
@@ -1325,7 +1347,7 @@ function MessageDetailContent({
1325
1347
  </ParticipantLink>
1326
1348
  </p>
1327
1349
  )}
1328
- {security && <SecurityIndicator security={security} />}
1350
+ {security && <SecurityIndicator security={security} zeroAccess={!!message.zeroAccess} />}
1329
1351
  </div>
1330
1352
  {senderNameCheck.misleading && (
1331
1353
  <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
@@ -1711,7 +1733,7 @@ function MessageDetailContent({
1711
1733
  body never came through the server's own sanitizer (the server never saw the plaintext); `MessageBody` sanitizes every body
1712
1734
  client-side regardless, behind a no-remote-loads CSP, in a frame that runs no script. */}
1713
1735
  <div ref={bodyRegionRef} tabIndex={-1} className="outline-none">
1714
- {message.encrypted && security === null ? (
1736
+ {(message.encrypted || message.zeroAccess) && security === null ? (
1715
1737
  <BodySkeleton />
1716
1738
  ) : security?.decryptError !== undefined ? (
1717
1739
  // An encrypted message that could not be read: locked (no unlocked keys - the button asks the app's own unlock prompt, and the message
@@ -1804,7 +1826,7 @@ function MessageDetailContent({
1804
1826
  onClose={closeSource}
1805
1827
  mode={sourceView?.mode ?? "source"}
1806
1828
  source={sourceView?.source ?? { status: "loading" }}
1807
- encrypted={!!message.encrypted}
1829
+ encrypted={!!message.encrypted || !!message.zeroAccess}
1808
1830
  />
1809
1831
  <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
1810
1832
  <p className="text-sm text-text-muted mb-4">
@@ -2,7 +2,9 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { type Message, type Recipient, type RecipientType, listAttachments } from "../../../../../lib/mail/mailApi.js";
5
+ import { getUnlockedKeys } from "../../../../../lib/crypto/keySession.js";
6
+ import { evaluateMessageSecurity, type MessageSecurityResult } from "../../../../../lib/crypto/messageSecurity.js";
7
+ import { getMessageRawContent, type Message, type Recipient, type RecipientType, listAttachments } from "../../../../../lib/mail/mailApi.js";
6
8
  import { formatMailAddress } from "../../../../../lib/mail/mailAddress.js";
7
9
  import type { ApiClient } from "../../../../../lib/util/api.js";
8
10
  import type { ResumeCompose } from "../../../mail/outbox/composeBridge.js";
@@ -29,13 +31,22 @@ function textToHtml(text: string): string {
29
31
  * What the compose window needs to continue `draft` - a message in the Drafts folder the reader opened rather than one a window of this tab
30
32
  * saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
31
33
  * and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
34
+ * A draft saved in a zero-access mailbox is stored sealed to the mailbox's zero-access key: it is decrypted here with the unlocked keys (its
35
+ * subject comes from the decrypted message too, the record holding only `[...]`), and is `undefined` - left to read - while they're locked.
32
36
  * Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
33
37
  */
34
38
  export async function resumeFromDraft(draft: Message, client?: ApiClient): Promise<ResumeCompose | undefined> {
35
39
  if (draft.encrypted) {
36
40
  return undefined;
37
41
  }
38
- const [original, attachments] = await Promise.all([loadOriginalMessage(draft, null), listAttachments(draft.folderUid, draft.uid, client)]);
42
+ let security: MessageSecurityResult | null = null;
43
+ if (draft.zeroAccess) {
44
+ security = await openSealedDraft(draft, client);
45
+ if (!security) {
46
+ return undefined;
47
+ }
48
+ }
49
+ const [original, attachments] = await Promise.all([loadOriginalMessage(draft, security), listAttachments(draft.folderUid, draft.uid, client)]);
39
50
  const html =
40
51
  original.body.html !== undefined ? original.body.html : original.body.text !== undefined ? textToHtml(original.body.text) : textToHtml(draft.bodyPreview ?? "");
41
52
  return {
@@ -44,7 +55,7 @@ export async function resumeFromDraft(draft: Message, client?: ApiClient): Promi
44
55
  to: recipientText(draft.recipients, "to"),
45
56
  cc: recipientText(draft.recipients, "cc"),
46
57
  bcc: recipientText(draft.recipients, "bcc"),
47
- subject: draft.subject,
58
+ subject: security?.subject ?? draft.subject,
48
59
  html,
49
60
  attachments,
50
61
  requestReceipt: !!draft.requestReceipt,
@@ -52,3 +63,17 @@ export async function resumeFromDraft(draft: Message, client?: ApiClient): Promi
52
63
  encryptRequested: false,
53
64
  };
54
65
  }
66
+
67
+ /** `draft`'s sealed message decrypted with its mailbox's unlocked keys, or `null` when they're locked or it won't open with them. */
68
+ async function openSealedDraft(draft: Message, client?: ApiClient): Promise<MessageSecurityResult | null> {
69
+ const unlocked = getUnlockedKeys(draft.mailboxUid);
70
+ if (!unlocked) {
71
+ return null;
72
+ }
73
+ try {
74
+ const security = await evaluateMessageSecurity(await getMessageRawContent(draft.uid, client), unlocked);
75
+ return security.zeroAccess ? security : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }