@rapidmx/web-client 0.34.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 (148) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
  3. package/apps/shared/calendar/sealedEvents.ts +80 -0
  4. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
  5. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
  6. package/apps/shared/components/contacts/contactCardData.ts +8 -4
  7. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +23 -1
  8. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +91 -0
  9. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  10. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  11. package/apps/shared/components/mail/MessageDetailPane.tsx +91 -20
  12. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  13. package/apps/shared/components/mail/compose/quotedBody.ts +31 -15
  14. package/apps/shared/components/mail/invite/inviteStore.ts +7 -1
  15. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  16. package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
  17. package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
  18. package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
  19. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  20. package/apps/shared/components/settings/KeyExportSetting.tsx +170 -0
  21. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  22. package/apps/shared/components/settings/ZeroAccessSetting.tsx +117 -0
  23. package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
  24. package/apps/shared/mail/decryptedMessages.ts +11 -3
  25. package/apps/shared/mail/outbox/sendJob.ts +2 -2
  26. package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
  27. package/apps/shared/mail/reportNotices.ts +3 -1
  28. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  29. package/apps/shared/search/LocalIndexLifecycle.tsx +31 -0
  30. package/apps/shared/search/localIndexBuilder.ts +27 -1
  31. package/apps/shared/search/localIndexRpcClient.ts +8 -4
  32. package/apps/shared/search/localIndexSchema.ts +2 -2
  33. package/apps/shared/search/localIndexTransport.ts +2 -1
  34. package/apps/shared/search/localIndexWorker.ts +6 -4
  35. package/apps/shared/search/zeroAccessIndexSync.ts +487 -0
  36. package/apps/www/calendar/index.tsx +180 -58
  37. package/apps/www/contacts/index.tsx +58 -2
  38. package/apps/www/index.tsx +10 -3
  39. package/apps/www/settings/encryption/index.tsx +5 -0
  40. package/apps/www/settings/privacy/index.tsx +2 -1
  41. package/apps/www/tasks/index.tsx +42 -0
  42. package/dist/apps/escrow/matters/[uid].js +2 -2
  43. package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
  44. package/dist/apps/shared/calendar/sealedEvents.d.ts +13 -0
  45. package/dist/apps/shared/calendar/sealedEvents.js +54 -0
  46. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +18 -2
  47. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
  48. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  49. package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
  50. package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
  51. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +21 -2
  52. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +15 -0
  53. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +72 -0
  54. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  55. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  56. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  57. package/dist/apps/shared/components/mail/MessageDetailPane.js +73 -17
  58. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  59. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  60. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +4 -3
  61. package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -15
  62. package/dist/apps/shared/components/mail/invite/inviteStore.js +6 -1
  63. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  64. package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
  65. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
  66. package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
  67. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
  68. package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
  69. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  70. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  71. package/dist/apps/shared/components/settings/KeyExportSetting.js +88 -0
  72. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  73. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  74. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +14 -0
  75. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +71 -0
  76. package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
  77. package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
  78. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  79. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  80. package/dist/apps/shared/mail/outbox/sendJob.js +2 -2
  81. package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
  82. package/dist/apps/shared/mail/reportNotices.js +3 -1
  83. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  84. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  86. package/dist/apps/shared/search/LocalIndexLifecycle.js +28 -0
  87. package/dist/apps/shared/search/localIndexBuilder.d.ts +5 -0
  88. package/dist/apps/shared/search/localIndexBuilder.js +24 -2
  89. package/dist/apps/shared/search/localIndexRpcClient.d.ts +1 -1
  90. package/dist/apps/shared/search/localIndexRpcClient.js +6 -4
  91. package/dist/apps/shared/search/localIndexSchema.d.ts +1 -1
  92. package/dist/apps/shared/search/localIndexSchema.js +2 -2
  93. package/dist/apps/shared/search/localIndexTransport.d.ts +2 -1
  94. package/dist/apps/shared/search/localIndexWorker.d.ts +2 -0
  95. package/dist/apps/shared/search/localIndexWorker.js +4 -4
  96. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +71 -0
  97. package/dist/apps/shared/search/zeroAccessIndexSync.js +382 -0
  98. package/dist/apps/www/calendar/index.js +48 -22
  99. package/dist/apps/www/contacts/index.js +49 -3
  100. package/dist/apps/www/index.js +10 -3
  101. package/dist/apps/www/settings/encryption/index.js +3 -1
  102. package/dist/apps/www/settings/privacy/index.js +1 -1
  103. package/dist/apps/www/tasks/index.js +29 -1
  104. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  105. package/dist/lib/calendar/calendarApi.d.ts +6 -0
  106. package/dist/lib/calendar/inviteApi.js +27 -5
  107. package/dist/lib/calendar/sealedInvites.d.ts +11 -0
  108. package/dist/lib/calendar/sealedInvites.js +28 -0
  109. package/dist/lib/crypto/keyExport.d.ts +54 -0
  110. package/dist/lib/crypto/keyExport.js +161 -0
  111. package/dist/lib/crypto/keySession.d.ts +45 -1
  112. package/dist/lib/crypto/keySession.js +93 -2
  113. package/dist/lib/crypto/keyvaultApi.d.ts +88 -3
  114. package/dist/lib/crypto/keyvaultApi.js +32 -0
  115. package/dist/lib/crypto/messageSecurity.d.ts +30 -1
  116. package/dist/lib/crypto/messageSecurity.js +80 -4
  117. package/dist/lib/crypto/mime.d.ts +5 -0
  118. package/dist/lib/crypto/mime.js +25 -0
  119. package/dist/lib/crypto/sharedMailboxKeys.d.ts +50 -0
  120. package/dist/lib/crypto/sharedMailboxKeys.js +264 -0
  121. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  122. package/dist/lib/crypto/zeroAccessKey.js +66 -0
  123. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  124. package/dist/lib/mail/mailApi.d.ts +30 -2
  125. package/dist/lib/mail/mailApi.js +5 -1
  126. package/dist/lib/mail/pushClient.d.ts +10 -3
  127. package/dist/lib/mail/pushClient.js +18 -4
  128. package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
  129. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  130. package/dist/lib/tasks/tasksApi.d.ts +1 -0
  131. package/dist/lib/tasks/tasksApi.js +3 -0
  132. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  133. package/lib/calendar/calendarApi.ts +3 -0
  134. package/lib/calendar/inviteApi.ts +28 -5
  135. package/lib/calendar/sealedInvites.ts +33 -0
  136. package/lib/crypto/keyExport.ts +229 -0
  137. package/lib/crypto/keySession.ts +117 -3
  138. package/lib/crypto/keyvaultApi.ts +125 -3
  139. package/lib/crypto/messageSecurity.ts +110 -3
  140. package/lib/crypto/mime.ts +26 -0
  141. package/lib/crypto/sharedMailboxKeys.ts +324 -0
  142. package/lib/crypto/zeroAccessKey.ts +97 -0
  143. package/lib/mail/conversationsApi.ts +3 -0
  144. package/lib/mail/mailApi.ts +33 -3
  145. package/lib/mail/pushClient.ts +18 -4
  146. package/lib/search/zeroAccessIndexApi.ts +119 -0
  147. package/lib/tasks/tasksApi.ts +4 -0
  148. package/package.json +3 -3
@@ -12,6 +12,7 @@ import MoveToFolderDialog from "./MoveToFolderDialog.js";
12
12
  import InviteCard from "./InviteCard.js";
13
13
  import { isCalendarAttachment } from "./invite/inviteFormat.js";
14
14
  import { useMessageInvite } from "./invite/inviteStore.js";
15
+ import { rememberSealedInvite } from "../../../../lib/calendar/sealedInvites.js";
15
16
  import { getUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
16
17
  import { extractAddresses } from "../../../../lib/crypto/mime.js";
17
18
  import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "../../../../lib/crypto/keyvaultApi.js";
@@ -78,6 +79,9 @@ const SECURITY_INDICATOR = {
78
79
  // Verified when first opened (a verification seal), but not live: muted, never the green verified pill. A signer key
79
80
  // later reported compromised switches it to `LATER_COMPROMISED_INDICATOR_CLASS`.
80
81
  verified_at_first_open: { label: "Verified when first opened", className: "bg-surface-alt text-text-muted" },
82
+ // Sealed by the server on arrival (zero-access): distinct from "Encrypted" - the server held this message in the clear while it
83
+ // delivered it - and never worded as end-to-end, per the spec's indicator table.
84
+ protected_at_rest: { label: "Protected at rest", className: "bg-primary/5 text-text" },
81
85
  };
82
86
  /** The amber `verified_at_first_open` badge for a signer key since reported compromised. */
83
87
  const LATER_COMPROMISED_INDICATOR_CLASS = "bg-warning/15 text-text";
@@ -143,6 +147,21 @@ function downloadFromUrl(url, filename) {
143
147
  a.click();
144
148
  a.remove();
145
149
  }
150
+ /** How long an attachment saved through an explicit client keeps its object URL: the download has started well before this. */
151
+ const DOWNLOAD_URL_LIFETIME_MS = 60000;
152
+ /**
153
+ * Saves the file at `path` through `client` (its origin and token, which a link's `href` cannot carry): its bytes become an object URL handed to
154
+ * `downloadFromUrl()`. The URL stays in `urls` until it is revoked - after `DOWNLOAD_URL_LIFETIME_MS`, or when the pane unmounts.
155
+ */
156
+ async function downloadThroughClient(client, path, filename, urls) {
157
+ const url = URL.createObjectURL(await client.fetchBlob(path));
158
+ urls.add(url);
159
+ downloadFromUrl(url, filename);
160
+ setTimeout(() => {
161
+ urls.delete(url);
162
+ URL.revokeObjectURL(url);
163
+ }, DOWNLOAD_URL_LIFETIME_MS);
164
+ }
146
165
  /** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
147
166
  * opaque download (`application/octet-stream`), never rendered in this origin. */
148
167
  function downloadMimeAttachment(attachment) {
@@ -174,11 +193,14 @@ function IconAction({ icon, label, onClick, disabled, reason, busy, onPrefetch,
174
193
  const env = useKeyEnvironment();
175
194
  return (_jsx("button", { type: "button", "aria-label": label, title: reason ?? (shortcut ? withHint(label, shortcut, env) : label), "aria-keyshortcuts": shortcut ? ariaKeyShortcuts(shortcut, env) : undefined, "aria-busy": busy || undefined, disabled: disabled, onClick: onClick, onPointerEnter: onPrefetch, onFocus: onPrefetch, className: ICON_BUTTON_CLASS, children: icon }));
176
195
  }
177
- function SecurityIndicator({ security }) {
178
- const { label, className } = SECURITY_INDICATOR[security.state];
196
+ function SecurityIndicator({ security, zeroAccess }) {
197
+ // 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
198
+ // labelled for what it is.
199
+ const state = zeroAccess && security.state === "encrypted" && security.decryptError !== undefined ? "protected_at_rest" : security.state;
200
+ const { label, className } = SECURITY_INDICATOR[state];
179
201
  if (security.state !== "verified_at_first_open") {
180
202
  // Every encrypted state carries a small lock, so the badge reads at a glance; the words are unchanged.
181
- const encrypted = security.state.startsWith("encrypted");
203
+ const encrypted = state.startsWith("encrypted") || state === "protected_at_rest";
182
204
  return (_jsxs("span", { className: `inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`, children: [encrypted && _jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), label] }));
183
205
  }
184
206
  return (_jsxs("span", { className: `inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${security.laterCompromised ? LATER_COMPROMISED_INDICATOR_CLASS : className}`, children: [security.laterCompromised ? (_jsx(HiOutlineExclamationTriangle, { size: 12, "aria-hidden": "true" })) : (_jsx(HiOutlineCheck, { size: 12, "aria-hidden": "true" })), label] }));
@@ -241,7 +263,7 @@ export default function MessageDetailPane(props) {
241
263
  * surfaced as an attachment - so a message with neither flag can't carry a signature to verify, and
242
264
  * fetching its full raw source (attachments included) would be pure waste. */
243
265
  function needsRawSecurityEvaluation(message) {
244
- return !!message.encrypted || message.hasAttachments;
266
+ return !!message.encrypted || !!message.zeroAccess || message.hasAttachments;
245
267
  }
246
268
  function MessageDetailContent({ message: messageProp, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, folders, onMoved, onFolderCreated, onReceiptHandled, onArchived, onChanged, labels, onLabelsChanged, onLabelCreated, inThread, threadSubject, threadHeader, footer, shortcuts, }) {
247
269
  // A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
@@ -290,6 +312,17 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
290
312
  const { mailboxes, trackMessageChange } = useMailShell();
291
313
  const { requestUnlock } = useUnlockPrompt();
292
314
  const client = useApiClient();
315
+ // Object URLs of attachments saved through `client`, revoked when they have been left to lapse or when this pane unmounts.
316
+ const downloadUrls = useRef(new Set());
317
+ useEffect(() => {
318
+ const urls = downloadUrls.current;
319
+ return () => {
320
+ for (const url of urls) {
321
+ URL.revokeObjectURL(url);
322
+ }
323
+ urls.clear();
324
+ };
325
+ }, []);
293
326
  // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
294
327
  // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
295
328
  // state; see keySession.ts's own doc comment).
@@ -411,13 +444,13 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
411
444
  })
412
445
  .catch(() => {
413
446
  if (!cancelled) {
414
- setSecurity(message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" });
447
+ setSecurity(message.encrypted || message.zeroAccess ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" });
415
448
  }
416
449
  });
417
450
  return () => {
418
451
  cancelled = true;
419
452
  };
420
- }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
453
+ }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, message.zeroAccess?.keyFingerprint, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
421
454
  // Whether the body is the locked state right now (an encrypted message this device holds no unlocked keys for), for the subscription below.
422
455
  const lockedRef = useRef(false);
423
456
  lockedRef.current = security?.decryptError !== undefined && !getUnlockedKeys(message.mailboxUid);
@@ -526,11 +559,20 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
526
559
  const mailbox = readerMailbox ?? (await getMailbox(message.mailboxUid, {}, client).catch(() => undefined));
527
560
  return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
528
561
  }
562
+ /** Saves a listed attachment: through the explicit client when there is one (failures are reported, the file is an object URL), else as the
563
+ * cookie link always did. */
564
+ function saveAttachment(attachment) {
565
+ if (client) {
566
+ downloadThroughClient(client, `/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, attachment.filename, downloadUrls.current).catch((err) => notifyApiError(err, "Couldn't download this attachment"));
567
+ return;
568
+ }
569
+ downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename);
570
+ }
529
571
  /** Starts fetching what Reply/Reply All/Forward will need (the compose window's code, the original's body) as the
530
572
  * pointer or keyboard reaches one of their buttons, so the click itself finds it already on its way or here. */
531
573
  function prefetchReply() {
532
574
  prefetchComposeWindow();
533
- prefetchOriginalMessage(message);
575
+ prefetchOriginalMessage(message, client);
534
576
  }
535
577
  /**
536
578
  * Opens the compose window at once from what is already in memory - the message's own subject and sender, and the
@@ -554,7 +596,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
554
596
  // Never rejects: loadOriginalMessage() and ownAddresses() don't, and the rest is pure.
555
597
  const late = (async () => {
556
598
  const [original, own] = await Promise.all([
557
- loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
599
+ loadOriginalMessage(message, security, { recipients: kind === "replyAll" }, client),
558
600
  kind === "forward" ? [] : (knownOwn ?? ownAddresses()),
559
601
  ]);
560
602
  if (kind === "forward") {
@@ -778,7 +820,11 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
778
820
  // A decrypted message whose signature failed still only has these (the server list is just `smime.p7m`); they're
779
821
  // listed with a warning. A signed-only failure never carries recovered attachments.
780
822
  const innerAttachments = security?.attachments !== undefined &&
781
- (verifiedOrSealed || security.state === "encrypted" || security.state === "encrypted_unverified_signer" || security.state === "signature_failed")
823
+ (verifiedOrSealed ||
824
+ security.zeroAccess ||
825
+ security.state === "encrypted" ||
826
+ security.state === "encrypted_unverified_signer" ||
827
+ security.state === "signature_failed")
782
828
  ? security.attachments
783
829
  : undefined;
784
830
  // Any state with a valid signature shows the address actually signed for (the protected From when the message
@@ -828,7 +874,17 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
828
874
  // 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
829
875
  // server holds only its ciphertext (the calendar file is inside it), so there is nothing for it to find. Drafts and Outbox hold messages
830
876
  // that are being written or sent, not received invitations.
831
- const mayHoldInvite = !message.encrypted && !inOutbox && message.folderUid !== draftsFolderUid && attachments.some(isCalendarAttachment);
877
+ // A zero-access message's calendar file is inside the sealed copy: the server reads the invitation from the one decrypted here.
878
+ const sealedCalendar = security?.zeroAccess ? security.calendar : undefined;
879
+ // Registered while rendering, not in an effect: the invitation lookup below must find it the moment it is enabled. Writing the same
880
+ // file for the same message again is a no-op.
881
+ if (sealedCalendar !== undefined) {
882
+ rememberSealedInvite(message.uid, sealedCalendar);
883
+ }
884
+ const mayHoldInvite = !message.encrypted &&
885
+ !inOutbox &&
886
+ message.folderUid !== draftsFolderUid &&
887
+ (attachments.some(isCalendarAttachment) || sealedCalendar !== undefined);
832
888
  // 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):
833
889
  // 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.
834
890
  const { invite: shownInvite } = useMessageInvite(message.uid, mayHoldInvite);
@@ -842,10 +898,10 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
842
898
  * sanitized body - in a frame of its own; see `printMessage.ts`. */
843
899
  async function handlePrint() {
844
900
  try {
845
- const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
901
+ const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version, undefined, client));
846
902
  const older = await olderConversationMessages(message, client).catch(() => []);
847
903
  // A few at a time: a long conversation is hundreds of messages, each of them several requests.
848
- const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) => printableOlderMessage(other).catch(() => ({
904
+ const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) => printableOlderMessage(other, client).catch(() => ({
849
905
  subject: other.subject || "(no subject)",
850
906
  headers: printHeaders(other, formatMailAddress(other.from)),
851
907
  content: { kind: "text", text: "This message could not be loaded, so it is not printed." },
@@ -899,7 +955,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
899
955
  /** The rules page's "New mail filter", started from this message: its sender and (for a message the server can read) its subject. */
900
956
  function handleCreateRule() {
901
957
  const params = new URLSearchParams({ mailboxUid: message.mailboxUid, from: message.from.address });
902
- const subject = message.encrypted ? "" : withoutSubjectPrefixes(message.subject);
958
+ const subject = message.encrypted || message.zeroAccess ? "" : withoutSubjectPrefixes(message.subject);
903
959
  if (subject) {
904
960
  params.set("subject", subject);
905
961
  }
@@ -924,7 +980,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
924
980
  createRule: handleCreateRule,
925
981
  };
926
982
  // 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.
927
- const printReason = !message.encrypted
983
+ const printReason = !message.encrypted && !message.zeroAccess
928
984
  ? undefined
929
985
  : security === null
930
986
  ? "Still opening this message"
@@ -935,7 +991,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
935
991
  : "This message can't be read, so it can't be printed";
936
992
  // In Junk Email the card's button is the way back (Not junk), which only needs the right to change the message.
937
993
  const reportReason = !writable ? "View-only mailbox" : undefined;
938
- const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [_jsx(SenderAvatar, { from: senderRecipient }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (_jsxs("h2", { className: "relative min-w-0 flex-1", style: BODY_FONT_STYLE, children: [_jsx("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, "aria-label": `${cardUnread ? "Unread. " : ""}${senderLabel}`, className: ["absolute inset-0 w-full rounded-sm", ROW_FOCUS_CLASS].join(" ") }), _jsx("span", { className: "pointer-events-none relative flex text-sm", children: _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, label: `Contact card for ${senderLabel}`, className: "pointer-events-auto min-w-0 max-w-full", children: _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) }) }) })] })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From", " ", _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc"), context: cardContext }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc"), context: cardContext }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc"), context: cardContext })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
994
+ const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [_jsx(SenderAvatar, { from: senderRecipient }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (_jsxs("h2", { className: "relative min-w-0 flex-1", style: BODY_FONT_STYLE, children: [_jsx("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, "aria-label": `${cardUnread ? "Unread. " : ""}${senderLabel}`, className: ["absolute inset-0 w-full rounded-sm", ROW_FOCUS_CLASS].join(" ") }), _jsx("span", { className: "pointer-events-none relative flex text-sm", children: _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, label: `Contact card for ${senderLabel}`, className: "pointer-events-auto min-w-0 max-w-full", children: _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) }) }) })] })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From", " ", _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security, zeroAccess: !!message.zeroAccess })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc"), context: cardContext }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc"), context: cardContext }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc"), context: cardContext })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
939
995
  "inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt",
940
996
  viewOriginal ? "bg-primary/10" : "",
941
997
  ].join(" "), children: viewOriginal ? _jsx(HiOutlineMoon, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineSun, { size: 16, "aria-hidden": "true" }) })), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", shortcut: keyboard ? SHORTCUTS.mail.reply : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", shortcut: keyboard ? SHORTCUTS.mail.replyAll : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", shortcut: keyboard ? SHORTCUTS.mail.forward : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("forward") }), archivable && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", shortcut: keyboard ? SHORTCUTS.mail.archive : undefined, busy: archiving, disabled: archiving, onClick: handleArchive })), movable && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", shortcut: keyboard ? SHORTCUTS.mail.move : undefined, onClick: () => setMovePrompt(true) })), reportable && (_jsx(IconAction, { icon: inJunk ? _jsx(HiOutlineInboxArrowDown, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineNoSymbol, { size: 16, "aria-hidden": "true" }), label: inJunk ? "Not junk" : "Report junk", busy: messageActions.busy, disabled: messageActions.busy || reportReason !== undefined, reason: reportReason, onClick: () => void (inJunk ? messageActions.notJunk() : messageActions.reportJunk()) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } })), archivable && (_jsx(MessageMoreMenu, { actions: menuActions, senderAddress: message.from.address, read: currentLabelsMessage.flags.read === true, flagged: currentLabelsMessage.flags.flagged === true, writable: writable, busy: messageActions.busy, inJunk: inJunk, inDeletedItems: inDeletedItems, sent: !!isSentItems, ownSender: readerOwnAddresses.includes(message.from.address.toLowerCase()), canTrustSender: writable, printReason: printReason, composing: preparingCompose, triggerClassName: ICON_BUTTON_CLASS }))] })] }), (answered || forwarded) && (_jsx("p", { className: "px-4 py-1.5 text-xs text-text-muted bg-surface-alt border-y border-border", children: answered && forwarded ? "You replied to and forwarded this message." : answered ? "You replied to this message." : "You forwarded this message." })), _jsxs("div", { id: threadHeader?.bodyId, className: "px-4 pb-3 flex flex-col gap-2", children: [inThread && (subjectDiffers || !sameSubject(shownSubject, threadSubject)) && (_jsx("h3", { className: "text-sm font-semibold break-words", children: shownSubject })), _jsx("div", { className: "flex flex-wrap items-center gap-2 empty:hidden", children: sendingStatus }), inOutbox && message.scheduledSendError && _jsxs(Alert, { children: ["This message wasn\u2019t sent: ", message.scheduledSendError] }), cancelError && _jsx(Alert, { children: cancelError }), keyChangeNotice && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: keyChangeNotice })), security && UNVERIFIED_SIGNER_STATES.has(security.state) && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: ["Signed, but the signer isn\u2019t a trusted contact key, so the sender isn\u2019t verified.", security.signerEmails && security.signerEmails.length > 0 && _jsxs(_Fragment, { children: [" Certificate for ", security.signerEmails.join(", "), "."] }), security.signerFingerprint && (_jsxs(_Fragment, { children: [" ", "Fingerprint ", _jsx("span", { className: "font-mono text-xs break-all", children: security.signerFingerprint }), "."] }))] })), pendingConflict && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["This sender has a signing key change waiting for your review, so this signer can\u2019t be trusted from here.", " ", _jsx("a", { href: `/contacts/${encodeURIComponent(senderKeyState.contactUid)}`, className: "font-medium text-primary-dark hover:underline", children: "Review it in Contacts" })] })), trustableCertificate && (_jsx("div", { children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => {
@@ -955,6 +1011,6 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
955
1011
  .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
956
1012
  .map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && _jsx(Alert, { children: receiptError }), mayHoldInvite && _jsx(InviteCard, { messageUid: message.uid, headingLevel: inThread ? 3 : 2 }), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
957
1013
  ? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: isVCardAttachment({ filename: attachment.filename, mimeType: attachment.contentType }) ? (_jsx(VCardAttachmentChip, { label: attachment.filename ?? `Unnamed ${attachment.contentType} attachment`, message: message, loadText: async () => new TextDecoder().decode(attachment.decode() ?? new Uint8Array(0)), onDownload: () => downloadMimeAttachment(attachment) })) : (_jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` })) }, `${index}:${attachment.filename ?? ""}`))) }))
958
- : listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid), onDownload: () => downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename) })) : (_jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
959
- return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), messageActions.dialog, _jsx(MessageSourceDialog, { open: sourceView !== null, onClose: closeSource, mode: sourceView?.mode ?? "source", source: sourceView?.source ?? { status: "loading" }, encrypted: !!message.encrypted }), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
1014
+ : listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid, client), onDownload: () => saveAttachment(attachment) })) : client ? (_jsxs("button", { type: "button", onClick: () => saveAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) : (_jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: (message.encrypted || message.zeroAccess) && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
1015
+ return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), messageActions.dialog, _jsx(MessageSourceDialog, { open: sourceView !== null, onClose: closeSource, mode: sourceView?.mode ?? "source", source: sourceView?.source ?? { status: "loading" }, encrypted: !!message.encrypted || !!message.zeroAccess }), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
960
1016
  }
@@ -5,6 +5,8 @@ import type { ResumeCompose } from "../../../mail/outbox/composeBridge.js";
5
5
  * 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
6
6
  * saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
7
7
  * and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
8
+ * 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
9
+ * subject comes from the decrypted message too, the record holding only `[...]`), and is `undefined` - left to read - while they're locked.
8
10
  * Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
9
11
  */
10
12
  export declare function resumeFromDraft(draft: Message, client?: ApiClient): Promise<ResumeCompose | undefined>;
@@ -2,7 +2,9 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { listAttachments } from "../../../../../lib/mail/mailApi.js";
5
+ import { getUnlockedKeys } from "../../../../../lib/crypto/keySession.js";
6
+ import { evaluateMessageSecurity } from "../../../../../lib/crypto/messageSecurity.js";
7
+ import { getMessageRawContent, listAttachments } from "../../../../../lib/mail/mailApi.js";
6
8
  import { formatMailAddress } from "../../../../../lib/mail/mailAddress.js";
7
9
  import { loadOriginalMessage } from "./quotedBody.js";
8
10
  /** The comma-separated address text a recipient field holds, for the recipients of one type. */
@@ -24,13 +26,22 @@ function textToHtml(text) {
24
26
  * 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
25
27
  * saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
26
28
  * and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
29
+ * 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
30
+ * subject comes from the decrypted message too, the record holding only `[...]`), and is `undefined` - left to read - while they're locked.
27
31
  * Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
28
32
  */
29
33
  export async function resumeFromDraft(draft, client) {
30
34
  if (draft.encrypted) {
31
35
  return undefined;
32
36
  }
33
- const [original, attachments] = await Promise.all([loadOriginalMessage(draft, null), listAttachments(draft.folderUid, draft.uid, client)]);
37
+ let security = null;
38
+ if (draft.zeroAccess) {
39
+ security = await openSealedDraft(draft, client);
40
+ if (!security) {
41
+ return undefined;
42
+ }
43
+ }
44
+ const [original, attachments] = await Promise.all([loadOriginalMessage(draft, security), listAttachments(draft.folderUid, draft.uid, client)]);
34
45
  const html = original.body.html !== undefined ? original.body.html : original.body.text !== undefined ? textToHtml(original.body.text) : textToHtml(draft.bodyPreview ?? "");
35
46
  return {
36
47
  draft,
@@ -38,7 +49,7 @@ export async function resumeFromDraft(draft, client) {
38
49
  to: recipientText(draft.recipients, "to"),
39
50
  cc: recipientText(draft.recipients, "cc"),
40
51
  bcc: recipientText(draft.recipients, "bcc"),
41
- subject: draft.subject,
52
+ subject: security?.subject ?? draft.subject,
42
53
  html,
43
54
  attachments,
44
55
  requestReceipt: !!draft.requestReceipt,
@@ -46,3 +57,17 @@ export async function resumeFromDraft(draft, client) {
46
57
  encryptRequested: false,
47
58
  };
48
59
  }
60
+ /** `draft`'s sealed message decrypted with its mailbox's unlocked keys, or `null` when they're locked or it won't open with them. */
61
+ async function openSealedDraft(draft, client) {
62
+ const unlocked = getUnlockedKeys(draft.mailboxUid);
63
+ if (!unlocked) {
64
+ return null;
65
+ }
66
+ try {
67
+ const security = await evaluateMessageSecurity(await getMessageRawContent(draft.uid, client), unlocked);
68
+ return security.zeroAccess ? security : null;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
@@ -1,3 +1,4 @@
1
+ import { type ApiClient } from "../../../../../lib/util/api.js";
1
2
  import { Message, Recipient } from "../../../../../lib/mail/mailApi.js";
2
3
  import type { QuotedBody } from "../../../../../lib/mail/compose/composeQuoting.js";
3
4
  import type { MessageSecurityResult } from "../../../../../lib/crypto/messageSecurity.js";
@@ -17,9 +18,9 @@ export declare const QUOTE_CACHE_MS = 30000;
17
18
  /**
18
19
  * Starts fetching what a Reply/Forward of `message` will quote, without waiting for the click - called when the pointer or
19
20
  * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
20
- * Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
21
+ * Nothing to fetch for an encrypted or zero-access message (`loadOriginalMessage()` never asks the server for one's body).
21
22
  */
22
- export declare function prefetchOriginalMessage(message: Message): void;
23
+ export declare function prefetchOriginalMessage(message: Message, client?: ApiClient): void;
23
24
  /** Forgets every remembered body (the sign-out and test seam). */
24
25
  export declare function clearOriginalMessageCache(): void;
25
26
  /**
@@ -50,4 +51,4 @@ export declare function clearOriginalMessageCache(): void;
50
51
  */
51
52
  export declare function loadOriginalMessage(message: Message, security: MessageSecurityResult | null, options?: {
52
53
  recipients?: boolean;
53
- }): Promise<OriginalMessage>;
54
+ }, client?: ApiClient): Promise<OriginalMessage>;
@@ -28,9 +28,9 @@ const contentCache = new Map();
28
28
  /**
29
29
  * The server's sanitized HTML body of a message (`GET /mail/messages/:id/content`), or `undefined` when it has none (a
30
30
  * `text/plain` message is answered with its preview instead) or the request failed or timed out. Never rejects. Remembered
31
- * for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time.
31
+ * for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time. Through `client` when given, else the cookie request.
32
32
  */
33
- function fetchContentHtml(uid) {
33
+ function fetchContentHtml(uid, client) {
34
34
  const cached = contentCache.get(uid);
35
35
  if (cached && Date.now() - cached.at < QUOTE_CACHE_MS) {
36
36
  return cached.promise;
@@ -39,6 +39,10 @@ function fetchContentHtml(uid) {
39
39
  const timer = setTimeout(() => controller.abort(), QUOTE_FETCH_TIMEOUT_MS);
40
40
  const promise = (async () => {
41
41
  try {
42
+ if (client) {
43
+ const blob = await client.fetchBlob(`/mail/messages/${encodeURIComponent(uid)}/content`, { signal: controller.signal });
44
+ return blob.type.toLowerCase().includes("text/html") ? await blob.text() : undefined;
45
+ }
42
46
  const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), { credentials: "include", signal: controller.signal });
43
47
  return res.ok && (res.headers.get("content-type") ?? "").includes("text/html") ? await res.text() : undefined;
44
48
  }
@@ -74,11 +78,11 @@ function withTimeout(promise, ms) {
74
78
  /**
75
79
  * Starts fetching what a Reply/Forward of `message` will quote, without waiting for the click - called when the pointer or
76
80
  * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
77
- * Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
81
+ * Nothing to fetch for an encrypted or zero-access message (`loadOriginalMessage()` never asks the server for one's body).
78
82
  */
79
- export function prefetchOriginalMessage(message) {
80
- if (!message.encrypted) {
81
- void fetchContentHtml(message.uid);
83
+ export function prefetchOriginalMessage(message, client) {
84
+ if (!message.encrypted && !message.zeroAccess) {
85
+ void fetchContentHtml(message.uid, client);
82
86
  }
83
87
  }
84
88
  /** Forgets every remembered body (the sign-out and test seam). */
@@ -97,18 +101,26 @@ function headerRecipients(value, type) {
97
101
  }
98
102
  /** The `data:` URI of the picture a quoted image of `message` stands for - one of the message's own attachments, fetched with the session -
99
103
  * or `undefined` when there is none, it is not a raster image, it is too large or it could not be had. Never rejects. */
100
- function serverImageResolver(message) {
104
+ function serverImageResolver(message, client) {
101
105
  let attachments;
102
106
  return async (reference, signal) => {
103
107
  try {
104
- attachments ?? (attachments = listAttachments(message.folderUid, message.uid));
108
+ attachments ?? (attachments = listAttachments(message.folderUid, message.uid, client));
105
109
  const attachment = findInlineAttachment(await attachments, reference);
106
110
  const type = attachment?.mimeType.split(";")[0].trim().toLowerCase();
107
111
  if (!attachment || !type || !EMBEDDED_IMAGE_TYPES.test(type) || attachment.sizeBytes > MAX_QUOTED_IMAGE_BYTES) {
108
112
  return undefined;
109
113
  }
110
- const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include", signal });
111
- const bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
114
+ let bytes;
115
+ if (client) {
116
+ // A failed response rejects here, which the catch below turns into no picture - as an `!res.ok` answer always was.
117
+ const blob = await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, { signal });
118
+ bytes = new Uint8Array(await blob.arrayBuffer());
119
+ }
120
+ else {
121
+ const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include", signal });
122
+ bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
123
+ }
112
124
  return bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(type, bytes) : undefined;
113
125
  }
114
126
  catch {
@@ -152,7 +164,7 @@ function partImageResolver(parts) {
152
164
  *
153
165
  * The quote builders sanitize whatever HTML this returns (`sanitizeQuotedHtml()`), whichever source it came from.
154
166
  */
155
- export async function loadOriginalMessage(message, security, options = {}) {
167
+ export async function loadOriginalMessage(message, security, options = {}, client) {
156
168
  const recovered = security?.text !== undefined ? { text: security.text } : security?.html !== undefined ? { html: security.html } : undefined;
157
169
  // Headers inside an unsigned (or badly signed) encrypted message are whatever its encrypter chose to write, so only a
158
170
  // verified signature makes them a source of recipients.
@@ -161,14 +173,15 @@ export async function loadOriginalMessage(message, security, options = {}) {
161
173
  ? [...headerRecipients(protectedHeaders.to, "to"), ...headerRecipients(protectedHeaders.cc, "cc")]
162
174
  : [];
163
175
  const result = { body: recovered ?? {}, ...(protectedRecipients.length > 0 ? { recipients: protectedRecipients } : {}) };
164
- if (message.encrypted) {
176
+ // The server holds only ciphertext of an encrypted or zero-access message: its body is what `security` decrypted, or nothing.
177
+ if (message.encrypted || message.zeroAccess) {
165
178
  return result;
166
179
  }
167
180
  let needsRaw = !!options.recipients && result.recipients === undefined;
168
181
  if (!recovered) {
169
182
  // Never rejects; nothing (a plain-text message, a failure, a timeout) leaves the raw message below as the other way
170
183
  // to the body.
171
- const html = await fetchContentHtml(message.uid);
184
+ const html = await fetchContentHtml(message.uid, client);
172
185
  if (html !== undefined) {
173
186
  result.body = { html };
174
187
  }
@@ -178,7 +191,7 @@ export async function loadOriginalMessage(message, security, options = {}) {
178
191
  }
179
192
  try {
180
193
  if (needsRaw) {
181
- const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid), QUOTE_FETCH_TIMEOUT_MS));
194
+ const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid, client), QUOTE_FETCH_TIMEOUT_MS));
182
195
  if (!recovered && result.body.html === undefined) {
183
196
  const body = extractDisplayBody(entity);
184
197
  result.body = body.html !== undefined ? { html: body.html } : body.text !== undefined ? { text: body.text } : {};
@@ -194,7 +207,7 @@ export async function loadOriginalMessage(message, security, options = {}) {
194
207
  // message's own recipients.
195
208
  }
196
209
  if (result.body.html !== undefined) {
197
- const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message);
210
+ const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message, client);
198
211
  const html = result.body.html;
199
212
  // Bounded like the body's own request: a picture that has not arrived by then is cancelled and left out of the quote, not waited for -
200
213
  // the ones that have arrived stay.
@@ -4,6 +4,8 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { useCallback, useEffect, useReducer } from "react";
6
6
  import { 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 { useApiClient } from "../../../../../lib/util/apiClientContext.js";
8
10
  /**
9
11
  * A per-message cache of the calendar invitation the server reads out of a message, shared by everything that draws one: the reading pane's
@@ -37,7 +39,9 @@ function normalize(invite) {
37
39
  */
38
40
  export function loadInvite(uid, client) {
39
41
  const existing = entries.get(uid);
40
- if (existing && (existing.value === undefined || Date.now() - existing.at < INVITE_TTL_MS)) {
42
+ // "No invitation" for a zero-access message is asked again once its calendar file has been decrypted here (the list asks before that).
43
+ const stale = existing?.value === null && sealedInviteCalendar(uid) !== undefined;
44
+ if (existing && !stale && (existing.value === undefined || Date.now() - existing.at < INVITE_TTL_MS)) {
41
45
  return existing.promise;
42
46
  }
43
47
  const entry = {
@@ -100,3 +104,4 @@ export function useMessageInvite(uid, enabled = true) {
100
104
  const value = enabled ? entries.get(uid)?.value : undefined;
101
105
  return { invite: value ?? null, settled: value !== undefined, setInvite };
102
106
  }
107
+ 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 { 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";
@@ -264,7 +265,7 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
264
265
  // A shared/delegated mailbox the caller doesn't own is never provisioned from here - its keys
265
266
  // belong to its owner (or its admins), not to whoever happens to open it first - and neither is
266
267
  // the impersonated user's own mailbox: an administrator must never choose its password/recovery codes.
267
- canProvision: !impersonating && activeMailbox?.ownerUserUid === userUid, children: [_jsx(LocalIndexLifecycle, { mailboxUid: activeMailboxUid, mailboxKeys: activeMailbox?.keys, folders: mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? [],
268
+ canProvision: !impersonating && activeMailbox?.ownerUserUid === userUid, children: [_jsx(SharedMailboxKeyManager, { mailboxes: mailboxes, userUid: userUid }), _jsx(LocalIndexLifecycle, { mailboxUid: activeMailboxUid, mailboxKeys: activeMailbox?.keys, folders: mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? [],
268
269
  // A full page may be truncated - pruning against it would delete indexes of accessible mailboxes
269
270
  // beyond the limit, so it's only trusted as the complete list when it came back short.
270
271
  accessibleMailboxUids: status === "ready" && mailboxes.length < MAILBOX_LIST_LIMIT ? mailboxes.map((mb) => mb.uid) : undefined }), _jsx(AppShell, { active: "mail", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner })] }));
@@ -8,6 +8,7 @@ import { ApiRequestError, apiUrl } from "../../../../../lib/util/api.js";
8
8
  import Alert from "../../../../../lib/components/feedback/Alert.js";
9
9
  import Button from "../../../../../lib/components/buttons/Button.js";
10
10
  import Skeleton from "../../../../../lib/components/feedback/Skeleton.js";
11
+ import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
11
12
  import { useResolvedTheme } from "../../../appearance/resolvedTheme.js";
12
13
  import { attachmentSource, cachedBodyContent, fetchBodyContent, makeCidResolver } from "./bodyContent.js";
13
14
  import { prepareBodyHtml } from "./bodyHtml.js";
@@ -43,6 +44,8 @@ export default function MessageBody({ messageUid, messageVersion, title, content
43
44
  });
44
45
  const [attempt, setAttempt] = useState(0);
45
46
  const supplied = content !== undefined;
47
+ // The explicit ApiClient this body is rendered under, if any - undefined for the cookie-based web app.
48
+ const client = useApiClient();
46
49
  useEffect(() => {
47
50
  if (supplied) {
48
51
  return;
@@ -59,7 +62,7 @@ export default function MessageBody({ messageUid, messageVersion, title, content
59
62
  if (!revalidating) {
60
63
  setRemote({ status: "loading" });
61
64
  }
62
- fetchBodyContent(messageUid, messageVersion, controller.signal).then((loaded) => setRemote({ status: "ready", content: loaded, uid: messageUid }), (err) => {
65
+ fetchBodyContent(messageUid, messageVersion, controller.signal, client).then((loaded) => setRemote({ status: "ready", content: loaded, uid: messageUid }), (err) => {
63
66
  if (!controller.signal.aborted && !revalidating) {
64
67
  setRemote({ status: "error", message: err instanceof ApiRequestError ? err.message : "Could not load this message." });
65
68
  }
@@ -1,3 +1,4 @@
1
+ import { type ApiClient } from "../../../../../lib/util/api.js";
1
2
  import { Attachment } from "../../../../../lib/mail/mailApi.js";
2
3
  import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
3
4
  /** What a message's body is: server-sanitized HTML, or plain text (a message with no HTML part, or the server's plain-text fallback). */
@@ -17,9 +18,10 @@ export declare function cachedBodyContent(uid: string, version: number): BodyCon
17
18
  /**
18
19
  * The message's body from the server's `GET /mail/messages/:id/content`: the HTML the server sanitized when it ingested the message
19
20
  * (`sanitizedHtmlBlobKey`), or - for a message with none - the plain-text preview. A request that fails throws an `ApiRequestError`.
20
- * Successful answers are remembered per uid and version.
21
+ * Successful answers are remembered per uid and version. With an explicit `client` the request goes through it (its origin and token) instead of
22
+ * the `jwt` cookie; without one it is the cookie request it always was.
21
23
  */
22
- export declare function fetchBodyContent(uid: string, version: number, signal?: AbortSignal): Promise<BodyContent>;
24
+ export declare function fetchBodyContent(uid: string, version: number, signal?: AbortSignal, client?: ApiClient): Promise<BodyContent>;
23
25
  /** The largest inline image embedded into a body from a signed or encrypted message. */
24
26
  export declare const MAX_INLINE_IMAGE_BYTES = 5000000;
25
27
  /** A server attachment record as it may really arrive: the model carries the part's `Content-ID`, though the client type doesn't list it. */