@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
@@ -94,10 +94,10 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
94
94
  /**
95
95
  * Starts fetching what a Reply/Forward of `message` will quote, without waiting for the click - called when the pointer or
96
96
  * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
97
- * Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
97
+ * Nothing to fetch for an encrypted or zero-access message (`loadOriginalMessage()` never asks the server for one's body).
98
98
  */
99
99
  export function prefetchOriginalMessage(message: Message, client?: ApiClient): void {
100
- if (!message.encrypted) {
100
+ if (!message.encrypted && !message.zeroAccess) {
101
101
  void fetchContentHtml(message.uid, client);
102
102
  }
103
103
  }
@@ -201,7 +201,8 @@ export async function loadOriginalMessage(
201
201
  ? [...headerRecipients(protectedHeaders.to, "to"), ...headerRecipients(protectedHeaders.cc, "cc")]
202
202
  : [];
203
203
  const result: OriginalMessage = { body: recovered ?? {}, ...(protectedRecipients.length > 0 ? { recipients: protectedRecipients } : {}) };
204
- if (message.encrypted) {
204
+ // The server holds only ciphertext of an encrypted or zero-access message: its body is what `security` decrypted, or nothing.
205
+ if (message.encrypted || message.zeroAccess) {
205
206
  return result;
206
207
  }
207
208
  let needsRaw = !!options.recipients && result.recipients === undefined;
@@ -4,6 +4,8 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { useCallback, useEffect, useReducer } from "react";
6
6
  import { MessageInvite, getMessageInvite } from "../../../../../lib/calendar/inviteApi.js";
7
+ import { clearSealedInvites, sealedInviteCalendar } from "../../../../../lib/calendar/sealedInvites.js";
8
+ import { onSigningOut } from "../compose/composeFlushRegistry.js";
7
9
  import { ApiClient } from "../../../../../lib/util/api.js";
8
10
  import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
9
11
 
@@ -51,7 +53,9 @@ function normalize(invite: MessageInvite | null): MessageInvite | null {
51
53
  */
52
54
  export function loadInvite(uid: string, client?: ApiClient): Promise<MessageInvite | null> {
53
55
  const existing = entries.get(uid);
54
- if (existing && (existing.value === undefined || Date.now() - existing.at < INVITE_TTL_MS)) {
56
+ // "No invitation" for a zero-access message is asked again once its calendar file has been decrypted here (the list asks before that).
57
+ const stale = existing?.value === null && sealedInviteCalendar(uid) !== undefined;
58
+ if (existing && !stale && (existing.value === undefined || Date.now() - existing.at < INVITE_TTL_MS)) {
55
59
  return existing.promise;
56
60
  }
57
61
  const entry: Entry = {
@@ -120,3 +124,5 @@ export function useMessageInvite(uid: string, enabled = true): { invite: Message
120
124
  const value = enabled ? entries.get(uid)?.value : undefined;
121
125
  return { invite: value ?? null, settled: value !== undefined, setInvite };
122
126
  }
127
+
128
+ onSigningOut(clearSealedInvites);
@@ -15,6 +15,7 @@ import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
15
15
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
16
  import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
17
17
  import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
18
+ import SharedMailboxKeyManager from "../../layout/SharedMailboxKeyManager.js";
18
19
  import { LiveUpdates, NO_LIVE_UPDATES } from "../../../mail/useMailLiveUpdates.js";
19
20
  import { MAILBOX_LIST_LIMIT, MailConnectionContext, useMailConnection } from "../../../mail/useMailConnection.js";
20
21
  import { folderRows } from "../../../mail/folderTree.js";
@@ -538,6 +539,7 @@ export default function MailShell({
538
539
  // the impersonated user's own mailbox: an administrator must never choose its password/recovery codes.
539
540
  canProvision={!impersonating && activeMailbox?.ownerUserUid === userUid}
540
541
  >
542
+ <SharedMailboxKeyManager mailboxes={mailboxes} userUid={userUid} />
541
543
  <LocalIndexLifecycle
542
544
  mailboxUid={activeMailboxUid}
543
545
  mailboxKeys={activeMailbox?.keys}
@@ -28,6 +28,7 @@ import { resolveFolderOfType } from "../../../mail/folderOfType.js";
28
28
  import { setReadStateMany } from "../../../mail/messageReadState.js";
29
29
  import { usePermanentDelete } from "../../../mail/usePermanentDelete.js";
30
30
  import { reportFolderName, reportNotice, reportNoun, reportTitle, senderListsHref } from "../../../mail/reportNotices.js";
31
+ import { zeroAccessSourceBase64 } from "../../../mail/zeroAccessSource.js";
31
32
  import { blockSender, neverBlockSender, normalizeAddresses, removeSenderRule } from "../../../mail/senderBlocking.js";
32
33
  import { notifyApiError } from "../../../notifications/apiErrors.js";
33
34
  import { notify } from "../../../notifications/store.js";
@@ -190,7 +191,9 @@ export function useMessageActions(params: MessageActionsParams): MessageActions
190
191
  await exclusive(async () => {
191
192
  let result: MessageReportResult;
192
193
  try {
193
- result = await reportMessage(message.uid, kind, { alwaysTrustSender }, client);
194
+ // The server holds only the sealed copy of a zero-access message: its spam filter learns from the source decrypted here.
195
+ const rawMessage: string | undefined = await zeroAccessSourceBase64(message, client);
196
+ result = await reportMessage(message.uid, kind, { alwaysTrustSender, ...(rawMessage ? { rawMessage } : {}) }, client);
194
197
  } catch (err) {
195
198
  if (isNotFound(err)) {
196
199
  await reportByMoving(kind, alwaysTrustSender);
@@ -0,0 +1,170 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useState } from "react";
6
+ import { exportKeysAsPkcs12, MIN_EXPORT_PASSPHRASE_LENGTH, NothingToExportError } from "../../../../lib/crypto/keyExport.js";
7
+ import type { Mailbox } from "../../../../lib/mail/mailApi.js";
8
+ import { ApiRequestError } from "../../../../lib/util/api.js";
9
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
+ import Alert from "../../../../lib/components/feedback/Alert.js";
11
+ import Button from "../../../../lib/components/buttons/Button.js";
12
+ import { notifyApiError } from "../../notifications/apiErrors.js";
13
+ import PolicyLockedNotice from "./PolicyLockedNotice.js";
14
+
15
+ export interface KeyExportSettingProps {
16
+ mailbox: Mailbox;
17
+ /** The caller owns the mailbox and isn't impersonating: restapi records an export only for the owner. */
18
+ canManage: boolean;
19
+ }
20
+
21
+ const INPUT_CLASS = "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface";
22
+
23
+ /** Hands `bytes` to the browser as a file download. */
24
+ function saveFile(bytes: Uint8Array, filename: string): void {
25
+ const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: "application/x-pkcs12" }));
26
+ const link = document.createElement("a");
27
+ link.href = url;
28
+ link.download = filename;
29
+ document.body.appendChild(link);
30
+ link.click();
31
+ link.remove();
32
+ URL.revokeObjectURL(url);
33
+ }
34
+
35
+ /**
36
+ * "Export keys for Outlook/phone" (`lib/crypto/keyExport.ts`): a passphrase-protected `.p12` with the mailbox's zero-access
37
+ * and end-to-end encryption keys - and its signing key, if the user ticks the box - so a mail app that isn't RapidMX can read
38
+ * mail stored encrypted. Asks for the master password again however long the mailbox has been unlocked.
39
+ */
40
+ export default function KeyExportSetting({ mailbox, canManage }: KeyExportSettingProps) {
41
+ const client = useApiClient();
42
+ const [open, setOpen] = useState(false);
43
+ const [password, setPassword] = useState("");
44
+ const [passphrase, setPassphrase] = useState("");
45
+ const [confirmPassphrase, setConfirmPassphrase] = useState("");
46
+ const [includeSigningKey, setIncludeSigningKey] = useState(false);
47
+ const [exporting, setExporting] = useState(false);
48
+ const [error, setError] = useState<string | null>(null);
49
+
50
+ function reset() {
51
+ setOpen(false);
52
+ setPassword("");
53
+ setPassphrase("");
54
+ setConfirmPassphrase("");
55
+ setIncludeSigningKey(false);
56
+ setError(null);
57
+ }
58
+
59
+ async function handleExport(e: React.FormEvent) {
60
+ e.preventDefault();
61
+ if (passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) {
62
+ setError(`The file's passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters.`);
63
+ return;
64
+ }
65
+ if (passphrase !== confirmPassphrase) {
66
+ setError("The passphrases don't match.");
67
+ return;
68
+ }
69
+ setExporting(true);
70
+ setError(null);
71
+ try {
72
+ const result = await exportKeysAsPkcs12(
73
+ {
74
+ mailboxUid: mailbox.uid,
75
+ mailboxAddress: mailbox.primarySmtpAddress,
76
+ mailboxKeys: mailbox.keys ?? [],
77
+ masterPassword: password,
78
+ exportPassphrase: passphrase,
79
+ includeSigningKey,
80
+ },
81
+ client,
82
+ );
83
+ saveFile(result.pkcs12, `${mailbox.primarySmtpAddress}-keys.p12`);
84
+ reset();
85
+ } catch (err) {
86
+ if (err instanceof ApiRequestError) {
87
+ notifyApiError(err, "Couldn't export your keys");
88
+ } else if (err instanceof NothingToExportError || (err instanceof Error && err.message.startsWith("This mailbox has no password"))) {
89
+ setError(err.message);
90
+ } else {
91
+ setError("Incorrect password.");
92
+ }
93
+ } finally {
94
+ setExporting(false);
95
+ }
96
+ }
97
+
98
+ return (
99
+ <div>
100
+ <h2 className="text-sm font-semibold mb-2">Export keys for Outlook or a phone</h2>
101
+ <p className="text-sm text-text-muted mb-2">
102
+ Mail apps other than RapidMX need your keys to read encrypted mail. The exported file holds your zero-access and
103
+ end-to-end encryption keys, protected by a passphrase you choose. Anyone with the file and its passphrase can read
104
+ all of this mailbox&rsquo;s mail, so keep it safe and delete it once it&rsquo;s installed.
105
+ </p>
106
+ {!canManage ? (
107
+ <PolicyLockedNotice>Only this mailbox&rsquo;s owner, signed in as themselves, can export its keys.</PolicyLockedNotice>
108
+ ) : !open ? (
109
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setOpen(true)}>
110
+ Export keys&hellip;
111
+ </Button>
112
+ ) : (
113
+ <form onSubmit={(e) => void handleExport(e)} className="flex flex-col gap-2">
114
+ <input
115
+ type="password"
116
+ aria-label="Encryption password"
117
+ placeholder="Encryption password"
118
+ className={INPUT_CLASS}
119
+ value={password}
120
+ onChange={(e) => setPassword(e.target.value)}
121
+ disabled={exporting}
122
+ autoComplete="current-password"
123
+ />
124
+ <input
125
+ type="password"
126
+ aria-label="File passphrase"
127
+ placeholder={`File passphrase (at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters)`}
128
+ className={INPUT_CLASS}
129
+ value={passphrase}
130
+ onChange={(e) => setPassphrase(e.target.value)}
131
+ disabled={exporting}
132
+ autoComplete="new-password"
133
+ />
134
+ <input
135
+ type="password"
136
+ aria-label="Confirm file passphrase"
137
+ placeholder="Confirm file passphrase"
138
+ className={INPUT_CLASS}
139
+ value={confirmPassphrase}
140
+ onChange={(e) => setConfirmPassphrase(e.target.value)}
141
+ disabled={exporting}
142
+ autoComplete="new-password"
143
+ />
144
+ <label className="flex items-start gap-2 text-sm">
145
+ <input
146
+ type="checkbox"
147
+ className="mt-1"
148
+ checked={includeSigningKey}
149
+ onChange={(e) => setIncludeSigningKey(e.target.checked)}
150
+ disabled={exporting}
151
+ />
152
+ <span>
153
+ <span className="font-semibold block">Include my signing key</span>
154
+ <span className="text-xs text-text-muted">Only if that app will sign mail you send. Anyone with the file could sign as you.</span>
155
+ </span>
156
+ </label>
157
+ {error && <Alert>{error}</Alert>}
158
+ <div className="flex gap-2">
159
+ <Button type="submit" className="!w-auto" disabled={exporting || !password || !passphrase}>
160
+ {exporting ? "Exporting…" : "Export"}
161
+ </Button>
162
+ <Button type="button" variant="secondary" className="!w-auto" onClick={reset} disabled={exporting}>
163
+ Cancel
164
+ </Button>
165
+ </div>
166
+ </form>
167
+ )}
168
+ </div>
169
+ );
170
+ }
@@ -0,0 +1,23 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+
7
+ export interface PolicyLockedNoticeProps {
8
+ /** Why the setting next to this notice can't be changed - e.g. that an administrator turned it off for the server. */
9
+ children: React.ReactNode;
10
+ }
11
+
12
+ /**
13
+ * Explains why a settings control is disabled by a policy the user can't change themselves (an administrator's server-wide
14
+ * setting, say), shown right under that control. A disabled control with no reason reads as a defect; this says who decided
15
+ * and that the user isn't the one who can undo it.
16
+ */
17
+ export default function PolicyLockedNotice({ children }: PolicyLockedNoticeProps) {
18
+ return (
19
+ <p role="note" className="mt-1.5 text-xs text-text-muted border-l-2 border-border pl-2">
20
+ {children}
21
+ </p>
22
+ );
23
+ }
@@ -0,0 +1,117 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useState } from "react";
6
+ import { getEncryptionPolicy } from "../../../../lib/crypto/keyvaultApi.js";
7
+ import { getMailbox, type Mailbox, updateMailbox } from "../../../../lib/mail/mailApi.js";
8
+ import { ApiRequestError } from "../../../../lib/util/api.js";
9
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
+ import { notifyApiError } from "../../notifications/apiErrors.js";
11
+ import PolicyLockedNotice from "./PolicyLockedNotice.js";
12
+
13
+ /** 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
+ * by ticking the box. */
15
+ type Blocker = "disabled_by_admin" | "unsupported";
16
+
17
+ export interface ZeroAccessSettingProps {
18
+ mailbox: Mailbox;
19
+ /** The caller owns the mailbox and isn't impersonating: only then can the setting be changed (restapi drops anybody else's value). */
20
+ canManage: boolean;
21
+ }
22
+
23
+ /**
24
+ * "Enable zero-access encryption" for one mailbox (restapi `specs/search.md` §3): unencrypted mail is encrypted to the
25
+ * mailbox's zero-access key as it arrives and the server keeps no readable copy. On by default. Shown disabled, with the
26
+ * reason, when the administrator has turned "Allow zero-access encryption" off for the server, or when the server doesn't
27
+ * support it. A shared mailbox is covered too: its members read it through member key wraps (`sharedMailboxKeys.ts`).
28
+ */
29
+ export default function ZeroAccessSetting({ mailbox, canManage }: ZeroAccessSettingProps) {
30
+ const client = useApiClient();
31
+ const [enabled, setEnabled] = useState(mailbox.zeroAccessEnabled !== false);
32
+ const [blocker, setBlocker] = useState<Blocker | null | undefined>(undefined);
33
+ const [saving, setSaving] = useState(false);
34
+
35
+ useEffect(() => {
36
+ let cancelled = false;
37
+ void (async () => {
38
+ const policy = await getEncryptionPolicy(client).catch(() => undefined);
39
+ let next: Blocker | null = null;
40
+ if (policy?.allowZeroAccess === undefined) {
41
+ next = "unsupported";
42
+ } else if (!policy.allowZeroAccess) {
43
+ next = "disabled_by_admin";
44
+ }
45
+ if (!cancelled) {
46
+ setBlocker(next);
47
+ }
48
+ })();
49
+ return () => {
50
+ cancelled = true;
51
+ };
52
+ }, [mailbox.uid, client]);
53
+
54
+ async function save(next: boolean) {
55
+ setSaving(true);
56
+ const previous = enabled;
57
+ setEnabled(next);
58
+ try {
59
+ const write = (version: number) => updateMailbox({ uid: mailbox.uid, version, zeroAccessEnabled: next }, client);
60
+ try {
61
+ await write(mailbox.version);
62
+ } catch (err) {
63
+ // Enrolling a key (which happens on unlock) moves the mailbox's version on, so the copy this page was given may be
64
+ // behind: read it again and retry once.
65
+ if (!(err instanceof ApiRequestError && err.status === 409)) {
66
+ throw err;
67
+ }
68
+ await write((await getMailbox(mailbox.uid, {}, client)).version);
69
+ }
70
+ } catch (err) {
71
+ setEnabled(previous);
72
+ notifyApiError(err, "Couldn't save the zero-access encryption setting");
73
+ } finally {
74
+ setSaving(false);
75
+ }
76
+ }
77
+
78
+ const locked = blocker !== null;
79
+ return (
80
+ <div>
81
+ <h2 className="text-sm font-semibold mb-2">Zero-access encryption</h2>
82
+ <label className="flex items-start gap-2 text-sm">
83
+ <input
84
+ type="checkbox"
85
+ className="mt-1"
86
+ checked={!locked && enabled}
87
+ disabled={!canManage || locked || saving}
88
+ onChange={(e) => void save(e.target.checked)}
89
+ />
90
+ <span>
91
+ <span className="font-semibold block">Enable zero-access encryption</span>
92
+ <span className="text-xs text-text-muted">
93
+ Mail that arrives unencrypted is encrypted the moment it is received, and the server keeps no copy it can read -
94
+ nobody else, administrators included, can read it. You unlock it with your encryption password, and search
95
+ of message contents happens on your device. Outlook and phone mail apps need your exported keys to read it.
96
+ Turning this off only affects mail that arrives afterwards.
97
+ </span>
98
+ </span>
99
+ </label>
100
+ {blocker === "disabled_by_admin" && (
101
+ <PolicyLockedNotice>
102
+ Your administrator has turned off zero-access encryption for this server, so new mail is stored without it. Mail
103
+ already encrypted stays encrypted.
104
+ </PolicyLockedNotice>
105
+ )}
106
+ {blocker === "unsupported" && <PolicyLockedNotice>This server doesn&rsquo;t offer zero-access encryption.</PolicyLockedNotice>}
107
+ {!mailbox.ownerUserUid && !locked && (
108
+ <PolicyLockedNotice>
109
+ This shared mailbox has no owner: it is protected for its members, whose apps share its key with each other.
110
+ </PolicyLockedNotice>
111
+ )}
112
+ {!canManage && !locked && mailbox.ownerUserUid && (
113
+ <PolicyLockedNotice>Only this mailbox&rsquo;s owner, signed in as themselves, can change this.</PolicyLockedNotice>
114
+ )}
115
+ </div>
116
+ );
117
+ }
@@ -139,16 +139,24 @@ export function useUnlockEpoch(): number {
139
139
  * Decrypts one message to recover its subject and preview, when its mailbox's keys are unlocked and it isn't already known. Best effort: a
140
140
  * message that can't be fetched or opened is simply left as it was. Never prompts.
141
141
  */
142
- export async function decryptForDisplay(uid: string, mailboxUid: string): Promise<void> {
142
+ export async function decryptForDisplay(uid: string, mailboxUid: string, sealedSummary?: string): Promise<void> {
143
143
  const unlocked = getUnlockedKeys(mailboxUid);
144
144
  if (!unlocked || entries[uid] || inFlight.has(uid) || decryptFailedBefore(uid)) {
145
145
  return;
146
146
  }
147
147
  inFlight.add(uid);
148
148
  try {
149
+ const securityApi = await import("../../../lib/crypto/messageSecurity.js");
150
+ // A message sealed at rest has its subject and preview sealed on its row: opening that small summary saves downloading it.
151
+ const summary = sealedSummary ? await securityApi.openZeroAccessSummary(sealedSummary, unlocked.zeroAccessKeys) : undefined;
152
+ if (summary) {
153
+ if (getUnlockedKeys(mailboxUid) === unlocked) {
154
+ rememberDecrypted(uid, mailboxUid, { subject: summary.subject, preview: summary.preview.slice(0, PREVIEW_MAX_LENGTH) });
155
+ }
156
+ return;
157
+ }
149
158
  const rawMime = await getMessageRawContent(uid);
150
- const { evaluateMessageSecurity } = await import("../../../lib/crypto/messageSecurity.js");
151
- const security = await evaluateMessageSecurity(rawMime, unlocked);
159
+ const security = await securityApi.evaluateMessageSecurity(rawMime, unlocked);
152
160
  // A lock while this ran: the result must not go back on screen.
153
161
  if (getUnlockedKeys(mailboxUid) === unlocked) {
154
162
  rememberDecrypted(uid, mailboxUid, { subject: security.subject, preview: security.html ? previewOf(security.html) : security.text?.slice(0, PREVIEW_MAX_LENGTH) });
@@ -193,7 +193,7 @@ async function assemble(
193
193
  ): Promise<Message> {
194
194
  const { draft, to, cc, bcc, subject, html } = request;
195
195
  if (mode === "plain") {
196
- return assembleDraft(draft.uid, { to, cc, bcc, subject, html }, client);
196
+ return assembleDraft(draft.uid, { to, cc, bcc, subject, html, intent: "send" }, client);
197
197
  }
198
198
  const keys = readSendKeys(request.mailboxUid, request.signEnabled, mailbox);
199
199
  const own = mailbox!;
@@ -227,7 +227,7 @@ async function assemble(
227
227
  )
228
228
  : await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing!.certDer, signing!.privateKey);
229
229
  const rawMime = assembleOutboundMime(outerHeaders, mimePart);
230
- return assembleDraftRaw(draft.uid, { to, cc, bcc, subject: outerHeaders.subject, rawMime }, client);
230
+ return assembleDraftRaw(draft.uid, { to, cc, bcc, subject: outerHeaders.subject, rawMime, intent: "send" }, client);
231
231
  }
232
232
 
233
233
  function failureOf(err: unknown, fallback: string): Failure {
@@ -61,6 +61,8 @@ export function reportNotice(result: MessageReportResult, subject: string, alway
61
61
  ? "The spam filter could not be trained from this report."
62
62
  : result.learnSkipped === "encrypted"
63
63
  ? "Not used to train the spam filter: the message is encrypted."
64
- : undefined;
64
+ : result.learnSkipped === "zero_access"
65
+ ? "Not used to train the spam filter: unlock your mailbox to let it learn from protected mail."
66
+ : undefined;
65
67
  return { title: kind.title, message: parts.join(" "), hint };
66
68
  }
@@ -0,0 +1,27 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { getUnlockedKeys } from "../../../lib/crypto/keySession.js";
6
+ import { openZeroAccessMessage } from "../../../lib/crypto/messageSecurity.js";
7
+ import { getMessageRawContent, type Message } from "../../../lib/mail/mailApi.js";
8
+ import type { ApiClient } from "../../../lib/util/api.js";
9
+
10
+ /**
11
+ * The original source of a zero-access `message`, decrypted with its mailbox's unlocked keys and base64-encoded - what the server, which holds
12
+ * only the sealed copy, needs for an action that reads the message itself (`reportMessage()`'s `rawMessage`). `undefined` for any other
13
+ * message, while the mailbox is locked, or when it can't be fetched or opened: the action then goes ahead without it.
14
+ */
15
+ export async function zeroAccessSourceBase64(message: Pick<Message, "uid" | "mailboxUid" | "zeroAccess">, client?: ApiClient): Promise<string | undefined> {
16
+ const unlocked = message.zeroAccess ? getUnlockedKeys(message.mailboxUid) : undefined;
17
+ if (!unlocked) {
18
+ return undefined;
19
+ }
20
+ try {
21
+ const original = await openZeroAccessMessage(await getMessageRawContent(message.uid, client), unlocked);
22
+ // A binary string (one char per byte), which `btoa()` encodes as the bytes themselves.
23
+ return original === undefined ? undefined : btoa(original);
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
@@ -39,10 +39,20 @@ const loadBuilder = () =>
39
39
  builder = undefined;
40
40
  throw err;
41
41
  }));
42
+ /** The zero-access index sync (`zeroAccessIndexSync.ts`), loaded the same way: only once a zero-access mailbox is unlocked. */
43
+ let syncer: Promise<typeof import("./zeroAccessIndexSync.js")> | undefined;
44
+ const loadSyncer = () =>
45
+ (syncer ??= import("./zeroAccessIndexSync.js").catch((err) => {
46
+ syncer = undefined;
47
+ throw err;
48
+ }));
42
49
  import { destroyLocalIndex, pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
43
50
 
44
51
  export const POLL_INTERVAL_MS = 5_000;
45
52
 
53
+ /** How often an unlocked zero-access mailbox's local index is brought up to date with the server's sealed index. */
54
+ export const ZERO_ACCESS_SYNC_INTERVAL_MS = 120_000;
55
+
46
56
  export interface LocalIndexLifecycleProps {
47
57
  mailboxUid?: string;
48
58
  mailboxKeys?: PublicKey[];
@@ -65,6 +75,24 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
65
75
  // The latest props (and client), for the long-lived polling interval below.
66
76
  const latestRef = useRef({ mailboxUid, folders, client });
67
77
  latestRef.current = { mailboxUid, folders, client };
78
+ // mailboxUid -> when its last zero-access sync started. Set only once its first build finished (the sync writes into the index the
79
+ // build opened); a mailbox in `syncingRef` has a pass running, and never gets a second one alongside it.
80
+ const lastSyncRef = useRef(new Map<string, number>());
81
+ const syncingRef = useRef(new Set<string>());
82
+
83
+ function syncIfDue(targetMailboxUid: string, targetClient: ApiClient | undefined, first = false) {
84
+ const unlocked = getUnlockedKeys(targetMailboxUid);
85
+ const last = lastSyncRef.current.get(targetMailboxUid);
86
+ if (!unlocked?.zeroAccessKeys?.length || syncingRef.current.has(targetMailboxUid) || (!first && (last === undefined || Date.now() - last < ZERO_ACCESS_SYNC_INTERVAL_MS))) {
87
+ return;
88
+ }
89
+ lastSyncRef.current.set(targetMailboxUid, Date.now());
90
+ syncingRef.current.add(targetMailboxUid);
91
+ loadSyncer()
92
+ .then(({ syncZeroAccessIndex }) => syncZeroAccessIndex(targetMailboxUid, unlocked, targetClient))
93
+ .catch(() => undefined)
94
+ .finally(() => syncingRef.current.delete(targetMailboxUid));
95
+ }
68
96
 
69
97
  function startBuildIfUnlocked(targetMailboxUid: string, targetFolders: Folder[], targetClient: ApiClient | undefined) {
70
98
  const unlocked = getUnlockedKeys(targetMailboxUid);
@@ -76,6 +104,7 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
76
104
  // the rest of the app depends on.
77
105
  loadBuilder()
78
106
  .then(({ buildLocalIndex }) => buildLocalIndex(targetMailboxUid, unlocked, targetFolders, undefined, targetClient))
107
+ .then(() => syncIfDue(targetMailboxUid, targetClient, true))
79
108
  .catch(() => undefined);
80
109
  }
81
110
 
@@ -91,6 +120,7 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
91
120
  if (wasUnlocked && !getUnlockedKeys(trackedUid)) {
92
121
  // A present-to-absent transition: something just destroyed this mailbox's keys.
93
122
  trackedRef.current.set(trackedUid, false);
123
+ lastSyncRef.current.delete(trackedUid);
94
124
  // Stop the pass still running with the now-destroyed keys; the destroy itself also makes
95
125
  // the Worker reject anything that pass still sends.
96
126
  loadBuilder()
@@ -103,6 +133,7 @@ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMai
103
133
  const { mailboxUid: activeUid, folders: activeFolders, client: activeClient } = latestRef.current;
104
134
  if (activeUid) {
105
135
  startBuildIfUnlocked(activeUid, activeFolders, activeClient);
136
+ syncIfDue(activeUid, activeClient);
106
137
  }
107
138
  }, POLL_INTERVAL_MS);
108
139
  return () => clearInterval(interval);
@@ -24,6 +24,11 @@
24
24
  * fully searchable via Tier 1; indexing it here too would spend this index's bounded byte budget on
25
25
  * content that didn't need it.
26
26
  *
27
+ * **Shared with the mailbox's other devices (Source C).** In a mailbox with a zero-access key, a pass first imports the end-to-end mail
28
+ * other devices already decrypted (`importClientSegments()`), at each message's version, so it isn't decrypted again here; and what this
29
+ * pass decrypts it uploads for them, sealed to the zero-access key (`uploadClientDocuments()`). Both are best effort - see
30
+ * `zeroAccessIndexSync.ts`.
31
+ *
27
32
  * **Verification seals.** A message this pass decrypts that has no seal for the vault's current `masterKeyGeneration` is
28
33
  * evaluated with `evaluateMessageSecurityWithSeal()`, against the sender's pinned signing keys from the reader's
29
34
  * contacts (`pinnedSigners.ts`, the message pane's own source, looked up once per sender per pass), so a verified
@@ -44,6 +49,7 @@ import { currentVerificationSeal, readVaultGeneration, sendVerificationSeal, ver
44
49
  import type { LocalIndexEntity } from "./localIndexSchema.js";
45
50
  import {
46
51
  getIndexedVersions,
52
+ moveLocalEntity,
47
53
  indexLocalEntities,
48
54
  initLocalIndex,
49
55
  nextLocalIndexGeneration,
@@ -53,6 +59,7 @@ import {
53
59
  } from "./localIndexRpcClient.js";
54
60
  import { deriveLocalIndexKey } from "./localIndexKey.js";
55
61
  import { getLocalIndexByteBudget } from "./localIndexSizePreference.js";
62
+ import { importClientSegments, uploadClientDocuments } from "./zeroAccessIndexSync.js";
56
63
 
57
64
  /** The RFC 9788 placeholder subject every encrypted message's outer envelope carries server-side - see
58
65
  * `apps/www/index.tsx`'s identical constant and its own doc comment for the full citation. */
@@ -369,6 +376,9 @@ async function runBuildPass(
369
376
  await initLocalIndex({ mailboxUid, indexKey, generation });
370
377
  let { evictedBefore } = await setLocalIndexWindow(mailboxUid, windowConfig.timeFloorMonths, windowConfig.byteBudgetBytes, generation);
371
378
  await setLocalIndexBuilding(mailboxUid, true, { complete: false, generation });
379
+ // End-to-end mail another device already decrypted: imported at its version, so the walk below skips it.
380
+ await importClientSegments(mailboxUid, unlocked, client, { generation }).catch(() => undefined);
381
+ signal.throwIfAborted();
372
382
  // Mail arriving after this moment isn't guaranteed to be picked up by this pass.
373
383
  const coveredUntil = new Date(Date.now() - CLOCK_SKEW_MARGIN_MS).toISOString();
374
384
  let cutoff: Date | undefined;
@@ -389,6 +399,8 @@ async function runBuildPass(
389
399
  .filter((f) => MESSAGE_FOLDER_TYPES.has(f.type))
390
400
  .sort((a, b) => (FOLDER_PRIORITY[a.type] ?? 99) - (FOLDER_PRIORITY[b.type] ?? 99));
391
401
  const seenEncryptedUids = new Set<string>();
402
+ // What this pass decrypted, for the mailbox's other devices.
403
+ const decrypted: LocalIndexEntity[] = [];
392
404
 
393
405
  async function walkFolder(folder: Folder): Promise<WalkOutcome> {
394
406
  for (let page = 0; ; page++) {
@@ -406,8 +418,20 @@ async function runBuildPass(
406
418
  for (const m of encrypted) {
407
419
  seenEncryptedUids.add(m.uid);
408
420
  }
421
+ // A zero-access message's content comes from the server's sealed index (`zeroAccessIndexSync.ts`), never decrypted here:
422
+ // only its folder is recorded on its row - and remembered by version, so an unchanged one costs nothing next time.
423
+ const sealed = encrypted.filter((m) => m.zeroAccess);
424
+ if (sealed.length > 0) {
425
+ const indexed = await getIndexedVersions(
426
+ mailboxUid,
427
+ sealed.map((m) => m.uid),
428
+ );
429
+ for (const m of sealed.filter((s) => indexed[s.uid] !== versionOf(s))) {
430
+ await moveLocalEntity(mailboxUid, m.uid, m.folderUid, versionOf(m));
431
+ }
432
+ }
409
433
  // Anything older than the eviction watermark would only be inserted and evicted again.
410
- const candidates = encrypted.filter((m) => !isOlderThan(m.receivedDate, evictedBefore));
434
+ const candidates = encrypted.filter((m) => !m.zeroAccess && !isOlderThan(m.receivedDate, evictedBefore));
411
435
  if (candidates.length > 0) {
412
436
  // Skip anything already indexed at the same version - a re-unlock used to re-fetch and
413
437
  // re-decrypt the entire window every time.
@@ -424,6 +448,7 @@ async function runBuildPass(
424
448
  const entities = built.filter((e): e is LocalIndexEntity => e !== undefined && e !== FETCH_FAILED);
425
449
  if (entities.length > 0) {
426
450
  ({ evictedBefore } = await indexLocalEntities(mailboxUid, entities, generation));
451
+ decrypted.push(...entities);
427
452
  }
428
453
  }
429
454
  const oldestOnPage = messages[messages.length - 1].receivedDate;
@@ -481,6 +506,7 @@ async function runBuildPass(
481
506
  const since = evictedBefore && isWithinTimeFloor(evictedBefore, cutoff) ? evictedBefore : cutoff?.toISOString();
482
507
  await pruneLocalEntities(mailboxUid, [...seenEncryptedUids], since, { folderUids: reliable, generation });
483
508
  }
509
+ await uploadClientDocuments(mailboxUid, unlocked, decrypted, client).catch(() => undefined);
484
510
  } catch (err) {
485
511
  complete = false;
486
512
  throw err;