@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
@@ -16,7 +16,7 @@ import {
16
16
  HiOutlineNoSymbol,
17
17
  HiOutlineSun,
18
18
  } from "react-icons/hi2";
19
- import { ApiRequestError } from "../../../../lib/util/api.js";
19
+ import { ApiRequestError, type ApiClient } from "../../../../lib/util/api.js";
20
20
  import {
21
21
  Attachment,
22
22
  Folder,
@@ -48,6 +48,7 @@ import MoveToFolderDialog from "./MoveToFolderDialog.js";
48
48
  import InviteCard from "./InviteCard.js";
49
49
  import { isCalendarAttachment } from "./invite/inviteFormat.js";
50
50
  import { useMessageInvite } from "./invite/inviteStore.js";
51
+ import { rememberSealedInvite } from "../../../../lib/calendar/sealedInvites.js";
51
52
  import { getUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
52
53
  import type { MessageSecurityResult, SignatureFailureReason } from "../../../../lib/crypto/messageSecurity.js";
53
54
  import { extractAddresses, type MimeAttachment } from "../../../../lib/crypto/mime.js";
@@ -116,6 +117,9 @@ const SECURITY_INDICATOR: Record<MessageSecurityResult["state"], { label: string
116
117
  // Verified when first opened (a verification seal), but not live: muted, never the green verified pill. A signer key
117
118
  // later reported compromised switches it to `LATER_COMPROMISED_INDICATOR_CLASS`.
118
119
  verified_at_first_open: { label: "Verified when first opened", className: "bg-surface-alt text-text-muted" },
120
+ // Sealed by the server on arrival (zero-access): distinct from "Encrypted" - the server held this message in the clear while it
121
+ // delivered it - and never worded as end-to-end, per the spec's indicator table.
122
+ protected_at_rest: { label: "Protected at rest", className: "bg-primary/5 text-text" },
119
123
  };
120
124
 
121
125
  /** The amber `verified_at_first_open` badge for a signer key since reported compromised. */
@@ -200,6 +204,23 @@ function downloadFromUrl(url: string, filename: string): void {
200
204
  a.remove();
201
205
  }
202
206
 
207
+ /** How long an attachment saved through an explicit client keeps its object URL: the download has started well before this. */
208
+ const DOWNLOAD_URL_LIFETIME_MS = 60_000;
209
+
210
+ /**
211
+ * 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
212
+ * `downloadFromUrl()`. The URL stays in `urls` until it is revoked - after `DOWNLOAD_URL_LIFETIME_MS`, or when the pane unmounts.
213
+ */
214
+ async function downloadThroughClient(client: ApiClient, path: string, filename: string, urls: Set<string>): Promise<void> {
215
+ const url = URL.createObjectURL(await client.fetchBlob(path));
216
+ urls.add(url);
217
+ downloadFromUrl(url, filename);
218
+ setTimeout(() => {
219
+ urls.delete(url);
220
+ URL.revokeObjectURL(url);
221
+ }, DOWNLOAD_URL_LIFETIME_MS);
222
+ }
223
+
203
224
  /** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
204
225
  * opaque download (`application/octet-stream`), never rendered in this origin. */
205
226
  function downloadMimeAttachment(attachment: MimeAttachment): void {
@@ -272,11 +293,14 @@ function IconAction({
272
293
  );
273
294
  }
274
295
 
275
- function SecurityIndicator({ security }: { security: MessageSecurityResult }) {
276
- const { label, className } = SECURITY_INDICATOR[security.state];
296
+ function SecurityIndicator({ security, zeroAccess }: { security: MessageSecurityResult; zeroAccess?: boolean }) {
297
+ // A zero-access message this device can't open yet (keys locked) evaluates as an encrypted one it has no key for: it is still
298
+ // labelled for what it is.
299
+ const state = zeroAccess && security.state === "encrypted" && security.decryptError !== undefined ? "protected_at_rest" : security.state;
300
+ const { label, className } = SECURITY_INDICATOR[state];
277
301
  if (security.state !== "verified_at_first_open") {
278
302
  // Every encrypted state carries a small lock, so the badge reads at a glance; the words are unchanged.
279
- const encrypted = security.state.startsWith("encrypted");
303
+ const encrypted = state.startsWith("encrypted") || state === "protected_at_rest";
280
304
  return (
281
305
  <span className={`inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`}>
282
306
  {encrypted && <HiOutlineLockClosed size={12} aria-hidden="true" />}
@@ -456,7 +480,7 @@ export default function MessageDetailPane(props: MessageDetailPaneProps) {
456
480
  * surfaced as an attachment - so a message with neither flag can't carry a signature to verify, and
457
481
  * fetching its full raw source (attachments included) would be pure waste. */
458
482
  function needsRawSecurityEvaluation(message: Message): boolean {
459
- return !!message.encrypted || message.hasAttachments;
483
+ return !!message.encrypted || !!message.zeroAccess || message.hasAttachments;
460
484
  }
461
485
 
462
486
  function MessageDetailContent({
@@ -529,6 +553,17 @@ function MessageDetailContent({
529
553
  const { mailboxes, trackMessageChange } = useMailShell();
530
554
  const { requestUnlock } = useUnlockPrompt();
531
555
  const client = useApiClient();
556
+ // Object URLs of attachments saved through `client`, revoked when they have been left to lapse or when this pane unmounts.
557
+ const downloadUrls = useRef(new Set<string>());
558
+ useEffect(() => {
559
+ const urls = downloadUrls.current;
560
+ return () => {
561
+ for (const url of urls) {
562
+ URL.revokeObjectURL(url);
563
+ }
564
+ urls.clear();
565
+ };
566
+ }, []);
532
567
  // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
533
568
  // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
534
569
  // state; see keySession.ts's own doc comment).
@@ -658,14 +693,14 @@ function MessageDetailContent({
658
693
  .catch(() => {
659
694
  if (!cancelled) {
660
695
  setSecurity(
661
- message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
696
+ message.encrypted || message.zeroAccess ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
662
697
  );
663
698
  }
664
699
  });
665
700
  return () => {
666
701
  cancelled = true;
667
702
  };
668
- }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
703
+ }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, message.zeroAccess?.keyFingerprint, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
669
704
 
670
705
  // Whether the body is the locked state right now (an encrypted message this device holds no unlocked keys for), for the subscription below.
671
706
  const lockedRef = useRef(false);
@@ -783,11 +818,23 @@ function MessageDetailContent({
783
818
  return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
784
819
  }
785
820
 
821
+ /** Saves a listed attachment: through the explicit client when there is one (failures are reported, the file is an object URL), else as the
822
+ * cookie link always did. */
823
+ function saveAttachment(attachment: Attachment) {
824
+ if (client) {
825
+ downloadThroughClient(client, `/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, attachment.filename, downloadUrls.current).catch(
826
+ (err) => notifyApiError(err, "Couldn't download this attachment"),
827
+ );
828
+ return;
829
+ }
830
+ downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename);
831
+ }
832
+
786
833
  /** Starts fetching what Reply/Reply All/Forward will need (the compose window's code, the original's body) as the
787
834
  * pointer or keyboard reaches one of their buttons, so the click itself finds it already on its way or here. */
788
835
  function prefetchReply() {
789
836
  prefetchComposeWindow();
790
- prefetchOriginalMessage(message);
837
+ prefetchOriginalMessage(message, client);
791
838
  }
792
839
 
793
840
  /**
@@ -812,7 +859,7 @@ function MessageDetailContent({
812
859
  // Never rejects: loadOriginalMessage() and ownAddresses() don't, and the rest is pure.
813
860
  const late = (async (): Promise<ComposeLateInput> => {
814
861
  const [original, own] = await Promise.all([
815
- loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
862
+ loadOriginalMessage(message, security, { recipients: kind === "replyAll" }, client),
816
863
  kind === "forward" ? [] : (knownOwn ?? ownAddresses()),
817
864
  ]);
818
865
  if (kind === "forward") {
@@ -1038,7 +1085,11 @@ function MessageDetailContent({
1038
1085
  // listed with a warning. A signed-only failure never carries recovered attachments.
1039
1086
  const innerAttachments =
1040
1087
  security?.attachments !== undefined &&
1041
- (verifiedOrSealed || security.state === "encrypted" || security.state === "encrypted_unverified_signer" || security.state === "signature_failed")
1088
+ (verifiedOrSealed ||
1089
+ security.zeroAccess ||
1090
+ security.state === "encrypted" ||
1091
+ security.state === "encrypted_unverified_signer" ||
1092
+ security.state === "signature_failed")
1042
1093
  ? security.attachments
1043
1094
  : undefined;
1044
1095
  // Any state with a valid signature shows the address actually signed for (the protected From when the message
@@ -1092,7 +1143,18 @@ function MessageDetailContent({
1092
1143
  // Only a message the server can read and that carries a calendar file is worth asking about an invitation. An encrypted message is out: the
1093
1144
  // server holds only its ciphertext (the calendar file is inside it), so there is nothing for it to find. Drafts and Outbox hold messages
1094
1145
  // that are being written or sent, not received invitations.
1095
- const mayHoldInvite = !message.encrypted && !inOutbox && message.folderUid !== draftsFolderUid && attachments.some(isCalendarAttachment);
1146
+ // A zero-access message's calendar file is inside the sealed copy: the server reads the invitation from the one decrypted here.
1147
+ const sealedCalendar: string | undefined = security?.zeroAccess ? security.calendar : undefined;
1148
+ // Registered while rendering, not in an effect: the invitation lookup below must find it the moment it is enabled. Writing the same
1149
+ // file for the same message again is a no-op.
1150
+ if (sealedCalendar !== undefined) {
1151
+ rememberSealedInvite(message.uid, sealedCalendar);
1152
+ }
1153
+ const mayHoldInvite =
1154
+ !message.encrypted &&
1155
+ !inOutbox &&
1156
+ message.folderUid !== draftsFolderUid &&
1157
+ (attachments.some(isCalendarAttachment) || sealedCalendar !== undefined);
1096
1158
  // The calendar file of a message whose invitation card is drawn is the card, not a chip in the attachment list (a nameless "attachment" to click):
1097
1159
  // it is listed again if the card could not be drawn (no invitation the server can read, or the lookup failed), so the file is never out of reach.
1098
1160
  const { invite: shownInvite } = useMessageInvite(message.uid, mayHoldInvite);
@@ -1141,11 +1203,11 @@ function MessageDetailContent({
1141
1203
  * sanitized body - in a frame of its own; see `printMessage.ts`. */
1142
1204
  async function handlePrint() {
1143
1205
  try {
1144
- const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
1206
+ const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version, undefined, client));
1145
1207
  const older = await olderConversationMessages(message, client).catch(() => []);
1146
1208
  // A few at a time: a long conversation is hundreds of messages, each of them several requests.
1147
1209
  const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) =>
1148
- printableOlderMessage(other).catch(
1210
+ printableOlderMessage(other, client).catch(
1149
1211
  (): PrintableMessage => ({
1150
1212
  subject: other.subject || "(no subject)",
1151
1213
  headers: printHeaders(other, formatMailAddress(other.from)),
@@ -1207,7 +1269,7 @@ function MessageDetailContent({
1207
1269
  /** The rules page's "New mail filter", started from this message: its sender and (for a message the server can read) its subject. */
1208
1270
  function handleCreateRule() {
1209
1271
  const params = new URLSearchParams({ mailboxUid: message.mailboxUid, from: message.from.address });
1210
- const subject = message.encrypted ? "" : withoutSubjectPrefixes(message.subject);
1272
+ const subject = message.encrypted || message.zeroAccess ? "" : withoutSubjectPrefixes(message.subject);
1211
1273
  if (subject) {
1212
1274
  params.set("subject", subject);
1213
1275
  }
@@ -1233,7 +1295,7 @@ function MessageDetailContent({
1233
1295
  createRule: handleCreateRule,
1234
1296
  };
1235
1297
  // An encrypted message can be printed once it has been opened here: while it is still opening, or when it can't be, there is nothing to print.
1236
- const printReason = !message.encrypted
1298
+ const printReason = !message.encrypted && !message.zeroAccess
1237
1299
  ? undefined
1238
1300
  : security === null
1239
1301
  ? "Still opening this message"
@@ -1285,7 +1347,7 @@ function MessageDetailContent({
1285
1347
  </ParticipantLink>
1286
1348
  </p>
1287
1349
  )}
1288
- {security && <SecurityIndicator security={security} />}
1350
+ {security && <SecurityIndicator security={security} zeroAccess={!!message.zeroAccess} />}
1289
1351
  </div>
1290
1352
  {senderNameCheck.misleading && (
1291
1353
  <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
@@ -1643,9 +1705,18 @@ function MessageDetailContent({
1643
1705
  <VCardAttachmentChip
1644
1706
  label={`${attachment.filename} (${formatBytes(attachment.sizeBytes)})`}
1645
1707
  message={message}
1646
- loadText={() => fetchVCardText(attachment.uid)}
1647
- onDownload={() => downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename)}
1708
+ loadText={() => fetchVCardText(attachment.uid, client)}
1709
+ onDownload={() => saveAttachment(attachment)}
1648
1710
  />
1711
+ ) : client ? (
1712
+ // Through the explicit client a link cannot carry the token, so the file is fetched and saved from a button.
1713
+ <button
1714
+ type="button"
1715
+ onClick={() => saveAttachment(attachment)}
1716
+ className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
1717
+ >
1718
+ {attachment.filename} ({formatBytes(attachment.sizeBytes)})
1719
+ </button>
1649
1720
  ) : (
1650
1721
  <a
1651
1722
  href={attachmentContentUrl(attachment.uid)}
@@ -1662,7 +1733,7 @@ function MessageDetailContent({
1662
1733
  body never came through the server's own sanitizer (the server never saw the plaintext); `MessageBody` sanitizes every body
1663
1734
  client-side regardless, behind a no-remote-loads CSP, in a frame that runs no script. */}
1664
1735
  <div ref={bodyRegionRef} tabIndex={-1} className="outline-none">
1665
- {message.encrypted && security === null ? (
1736
+ {(message.encrypted || message.zeroAccess) && security === null ? (
1666
1737
  <BodySkeleton />
1667
1738
  ) : security?.decryptError !== undefined ? (
1668
1739
  // An encrypted message that could not be read: locked (no unlocked keys - the button asks the app's own unlock prompt, and the message
@@ -1755,7 +1826,7 @@ function MessageDetailContent({
1755
1826
  onClose={closeSource}
1756
1827
  mode={sourceView?.mode ?? "source"}
1757
1828
  source={sourceView?.source ?? { status: "loading" }}
1758
- encrypted={!!message.encrypted}
1829
+ encrypted={!!message.encrypted || !!message.zeroAccess}
1759
1830
  />
1760
1831
  <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
1761
1832
  <p className="text-sm text-text-muted mb-4">
@@ -2,7 +2,9 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { type Message, type Recipient, type RecipientType, listAttachments } from "../../../../../lib/mail/mailApi.js";
5
+ import { getUnlockedKeys } from "../../../../../lib/crypto/keySession.js";
6
+ import { evaluateMessageSecurity, type MessageSecurityResult } from "../../../../../lib/crypto/messageSecurity.js";
7
+ import { getMessageRawContent, type Message, type Recipient, type RecipientType, listAttachments } from "../../../../../lib/mail/mailApi.js";
6
8
  import { formatMailAddress } from "../../../../../lib/mail/mailAddress.js";
7
9
  import type { ApiClient } from "../../../../../lib/util/api.js";
8
10
  import type { ResumeCompose } from "../../../mail/outbox/composeBridge.js";
@@ -29,13 +31,22 @@ function textToHtml(text: string): string {
29
31
  * What the compose window needs to continue `draft` - a message in the Drafts folder the reader opened rather than one a window of this tab
30
32
  * saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
31
33
  * and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
34
+ * A draft saved in a zero-access mailbox is stored sealed to the mailbox's zero-access key: it is decrypted here with the unlocked keys (its
35
+ * subject comes from the decrypted message too, the record holding only `[...]`), and is `undefined` - left to read - while they're locked.
32
36
  * Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
33
37
  */
34
38
  export async function resumeFromDraft(draft: Message, client?: ApiClient): Promise<ResumeCompose | undefined> {
35
39
  if (draft.encrypted) {
36
40
  return undefined;
37
41
  }
38
- const [original, attachments] = await Promise.all([loadOriginalMessage(draft, null), listAttachments(draft.folderUid, draft.uid, client)]);
42
+ let security: MessageSecurityResult | null = null;
43
+ if (draft.zeroAccess) {
44
+ security = await openSealedDraft(draft, client);
45
+ if (!security) {
46
+ return undefined;
47
+ }
48
+ }
49
+ const [original, attachments] = await Promise.all([loadOriginalMessage(draft, security), listAttachments(draft.folderUid, draft.uid, client)]);
39
50
  const html =
40
51
  original.body.html !== undefined ? original.body.html : original.body.text !== undefined ? textToHtml(original.body.text) : textToHtml(draft.bodyPreview ?? "");
41
52
  return {
@@ -44,7 +55,7 @@ export async function resumeFromDraft(draft: Message, client?: ApiClient): Promi
44
55
  to: recipientText(draft.recipients, "to"),
45
56
  cc: recipientText(draft.recipients, "cc"),
46
57
  bcc: recipientText(draft.recipients, "bcc"),
47
- subject: draft.subject,
58
+ subject: security?.subject ?? draft.subject,
48
59
  html,
49
60
  attachments,
50
61
  requestReceipt: !!draft.requestReceipt,
@@ -52,3 +63,17 @@ export async function resumeFromDraft(draft: Message, client?: ApiClient): Promi
52
63
  encryptRequested: false,
53
64
  };
54
65
  }
66
+
67
+ /** `draft`'s sealed message decrypted with its mailbox's unlocked keys, or `null` when they're locked or it won't open with them. */
68
+ async function openSealedDraft(draft: Message, client?: ApiClient): Promise<MessageSecurityResult | null> {
69
+ const unlocked = getUnlockedKeys(draft.mailboxUid);
70
+ if (!unlocked) {
71
+ return null;
72
+ }
73
+ try {
74
+ const security = await evaluateMessageSecurity(await getMessageRawContent(draft.uid, client), unlocked);
75
+ return security.zeroAccess ? security : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { apiUrl } from "../../../../../lib/util/api.js";
5
+ import { type ApiClient, apiUrl } from "../../../../../lib/util/api.js";
6
6
  import { Message, Recipient, attachmentContentUrl, getMessageRawContent, listAttachments } from "../../../../../lib/mail/mailApi.js";
7
7
  import type { QuotedBody } from "../../../../../lib/mail/compose/composeQuoting.js";
8
8
  import { MAX_QUOTED_IMAGE_BYTES, QuotedImageReference, embedQuotedImages } from "../../../../../lib/mail/compose/quotedImages.js";
@@ -44,9 +44,9 @@ const contentCache = new Map<string, { at: number; promise: Promise<string | und
44
44
  /**
45
45
  * The server's sanitized HTML body of a message (`GET /mail/messages/:id/content`), or `undefined` when it has none (a
46
46
  * `text/plain` message is answered with its preview instead) or the request failed or timed out. Never rejects. Remembered
47
- * for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time.
47
+ * for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time. Through `client` when given, else the cookie request.
48
48
  */
49
- function fetchContentHtml(uid: string): Promise<string | undefined> {
49
+ function fetchContentHtml(uid: string, client?: ApiClient): Promise<string | undefined> {
50
50
  const cached = contentCache.get(uid);
51
51
  if (cached && Date.now() - cached.at < QUOTE_CACHE_MS) {
52
52
  return cached.promise;
@@ -55,6 +55,10 @@ function fetchContentHtml(uid: string): Promise<string | undefined> {
55
55
  const timer = setTimeout(() => controller.abort(), QUOTE_FETCH_TIMEOUT_MS);
56
56
  const promise = (async (): Promise<string | undefined> => {
57
57
  try {
58
+ if (client) {
59
+ const blob = await client.fetchBlob(`/mail/messages/${encodeURIComponent(uid)}/content`, { signal: controller.signal });
60
+ return blob.type.toLowerCase().includes("text/html") ? await blob.text() : undefined;
61
+ }
58
62
  const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), { credentials: "include", signal: controller.signal });
59
63
  return res.ok && (res.headers.get("content-type") ?? "").includes("text/html") ? await res.text() : undefined;
60
64
  } catch {
@@ -90,11 +94,11 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
90
94
  /**
91
95
  * Starts fetching what a Reply/Forward of `message` will quote, without waiting for the click - called when the pointer or
92
96
  * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
93
- * 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).
94
98
  */
95
- export function prefetchOriginalMessage(message: Message): void {
96
- if (!message.encrypted) {
97
- void fetchContentHtml(message.uid);
99
+ export function prefetchOriginalMessage(message: Message, client?: ApiClient): void {
100
+ if (!message.encrypted && !message.zeroAccess) {
101
+ void fetchContentHtml(message.uid, client);
98
102
  }
99
103
  }
100
104
 
@@ -116,18 +120,28 @@ function headerRecipients(value: string | undefined, type: "to" | "cc"): Recipie
116
120
 
117
121
  /** The `data:` URI of the picture a quoted image of `message` stands for - one of the message's own attachments, fetched with the session -
118
122
  * or `undefined` when there is none, it is not a raster image, it is too large or it could not be had. Never rejects. */
119
- function serverImageResolver(message: Message): (reference: QuotedImageReference, signal?: AbortSignal) => Promise<string | undefined> {
123
+ function serverImageResolver(
124
+ message: Message,
125
+ client?: ApiClient,
126
+ ): (reference: QuotedImageReference, signal?: AbortSignal) => Promise<string | undefined> {
120
127
  let attachments: Promise<Awaited<ReturnType<typeof listAttachments>>> | undefined;
121
128
  return async (reference, signal) => {
122
129
  try {
123
- attachments ??= listAttachments(message.folderUid, message.uid);
130
+ attachments ??= listAttachments(message.folderUid, message.uid, client);
124
131
  const attachment = findInlineAttachment(await attachments, reference);
125
132
  const type = attachment?.mimeType.split(";")[0].trim().toLowerCase();
126
133
  if (!attachment || !type || !EMBEDDED_IMAGE_TYPES.test(type) || attachment.sizeBytes > MAX_QUOTED_IMAGE_BYTES) {
127
134
  return undefined;
128
135
  }
129
- const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include", signal });
130
- const bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
136
+ let bytes: Uint8Array | undefined;
137
+ if (client) {
138
+ // A failed response rejects here, which the catch below turns into no picture - as an `!res.ok` answer always was.
139
+ const blob = await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, { signal });
140
+ bytes = new Uint8Array(await blob.arrayBuffer());
141
+ } else {
142
+ const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include", signal });
143
+ bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
144
+ }
131
145
  return bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(type, bytes) : undefined;
132
146
  } catch {
133
147
  return undefined;
@@ -176,6 +190,7 @@ export async function loadOriginalMessage(
176
190
  message: Message,
177
191
  security: MessageSecurityResult | null,
178
192
  options: { recipients?: boolean } = {},
193
+ client?: ApiClient,
179
194
  ): Promise<OriginalMessage> {
180
195
  const recovered: QuotedBody | undefined =
181
196
  security?.text !== undefined ? { text: security.text } : security?.html !== undefined ? { html: security.html } : undefined;
@@ -186,14 +201,15 @@ export async function loadOriginalMessage(
186
201
  ? [...headerRecipients(protectedHeaders.to, "to"), ...headerRecipients(protectedHeaders.cc, "cc")]
187
202
  : [];
188
203
  const result: OriginalMessage = { body: recovered ?? {}, ...(protectedRecipients.length > 0 ? { recipients: protectedRecipients } : {}) };
189
- 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) {
190
206
  return result;
191
207
  }
192
208
  let needsRaw = !!options.recipients && result.recipients === undefined;
193
209
  if (!recovered) {
194
210
  // Never rejects; nothing (a plain-text message, a failure, a timeout) leaves the raw message below as the other way
195
211
  // to the body.
196
- const html = await fetchContentHtml(message.uid);
212
+ const html = await fetchContentHtml(message.uid, client);
197
213
  if (html !== undefined) {
198
214
  result.body = { html };
199
215
  } else {
@@ -202,7 +218,7 @@ export async function loadOriginalMessage(
202
218
  }
203
219
  try {
204
220
  if (needsRaw) {
205
- const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid), QUOTE_FETCH_TIMEOUT_MS));
221
+ const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid, client), QUOTE_FETCH_TIMEOUT_MS));
206
222
  if (!recovered && result.body.html === undefined) {
207
223
  const body = extractDisplayBody(entity);
208
224
  result.body = body.html !== undefined ? { html: body.html } : body.text !== undefined ? { text: body.text } : {};
@@ -217,7 +233,7 @@ export async function loadOriginalMessage(
217
233
  // message's own recipients.
218
234
  }
219
235
  if (result.body.html !== undefined) {
220
- const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message);
236
+ const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message, client);
221
237
  const html = result.body.html;
222
238
  // 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 -
223
239
  // the ones that have arrived stay.
@@ -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}
@@ -9,6 +9,7 @@ import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
9
9
  import Alert from "../../../../../lib/components/feedback/Alert.js";
10
10
  import Button from "../../../../../lib/components/buttons/Button.js";
11
11
  import Skeleton from "../../../../../lib/components/feedback/Skeleton.js";
12
+ import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
12
13
  import { useResolvedTheme } from "../../../appearance/resolvedTheme.js";
13
14
  import { BodyContent, attachmentSource, cachedBodyContent, fetchBodyContent, makeCidResolver } from "./bodyContent.js";
14
15
  import { prepareBodyHtml } from "./bodyHtml.js";
@@ -78,6 +79,8 @@ export default function MessageBody({ messageUid, messageVersion, title, content
78
79
  });
79
80
  const [attempt, setAttempt] = useState(0);
80
81
  const supplied = content !== undefined;
82
+ // The explicit ApiClient this body is rendered under, if any - undefined for the cookie-based web app.
83
+ const client = useApiClient();
81
84
 
82
85
  useEffect(() => {
83
86
  if (supplied) {
@@ -95,7 +98,7 @@ export default function MessageBody({ messageUid, messageVersion, title, content
95
98
  if (!revalidating) {
96
99
  setRemote({ status: "loading" });
97
100
  }
98
- fetchBodyContent(messageUid, messageVersion, controller.signal).then(
101
+ fetchBodyContent(messageUid, messageVersion, controller.signal, client).then(
99
102
  (loaded) => setRemote({ status: "ready", content: loaded, uid: messageUid }),
100
103
  (err) => {
101
104
  if (!controller.signal.aborted && !revalidating) {
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { ApiRequestError, apiUrl } from "../../../../../lib/util/api.js";
5
+ import { ApiRequestError, type ApiClient, apiUrl } from "../../../../../lib/util/api.js";
6
6
  import { Attachment, attachmentContentUrl } from "../../../../../lib/mail/mailApi.js";
7
7
  import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
8
8
  import { bytesToBase64, findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
@@ -31,28 +31,38 @@ export function cachedBodyContent(uid: string, version: number): BodyContent | u
31
31
  /**
32
32
  * The message's body from the server's `GET /mail/messages/:id/content`: the HTML the server sanitized when it ingested the message
33
33
  * (`sanitizedHtmlBlobKey`), or - for a message with none - the plain-text preview. A request that fails throws an `ApiRequestError`.
34
- * Successful answers are remembered per uid and version.
34
+ * Successful answers are remembered per uid and version. With an explicit `client` the request goes through it (its origin and token) instead of
35
+ * the `jwt` cookie; without one it is the cookie request it always was.
35
36
  */
36
- export async function fetchBodyContent(uid: string, version: number, signal?: AbortSignal): Promise<BodyContent> {
37
+ export async function fetchBodyContent(uid: string, version: number, signal?: AbortSignal, client?: ApiClient): Promise<BodyContent> {
37
38
  const cached = cachedBodyContent(uid, version);
38
39
  if (cached) {
39
40
  return cached;
40
41
  }
41
- // Says what it can show, as a document request would: the server answers with the sanitized HTML, or its plain-text fallback.
42
- const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), {
43
- credentials: "include",
44
- headers: { Accept: ACCEPT },
45
- signal,
46
- });
47
- if (!res.ok) {
48
- const contentType = res.headers.get("content-type") ?? "";
49
- const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
50
- throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
42
+ const path = `/mail/messages/${encodeURIComponent(uid)}/content`;
43
+ let text: string;
44
+ let contentType: string;
45
+ if (client) {
46
+ // `fetchBlob()` rejects with the same `ApiRequestError` a failed response gives, and its blob carries the response's own type.
47
+ const blob = await client.fetchBlob(path, { headers: { Accept: ACCEPT }, signal });
48
+ text = await blob.text();
49
+ contentType = blob.type;
50
+ } else {
51
+ // Says what it can show, as a document request would: the server answers with the sanitized HTML, or its plain-text fallback.
52
+ const res = await fetch(apiUrl(path), {
53
+ credentials: "include",
54
+ headers: { Accept: ACCEPT },
55
+ signal,
56
+ });
57
+ if (!res.ok) {
58
+ const resType = res.headers.get("content-type") ?? "";
59
+ const body = resType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
60
+ throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
61
+ }
62
+ text = await res.text();
63
+ contentType = res.headers.get("content-type") ?? "";
51
64
  }
52
- const text = await res.text();
53
- const content: BodyContent = (res.headers.get("content-type") ?? "").toLowerCase().includes("text/html")
54
- ? { kind: "html", html: text }
55
- : { kind: "text", text };
65
+ const content: BodyContent = contentType.toLowerCase().includes("text/html") ? { kind: "html", html: text } : { kind: "text", text };
56
66
  if (cache.size >= CACHE_LIMIT) {
57
67
  cache.delete(cache.keys().next().value as string);
58
68
  }
@@ -40,15 +40,16 @@ export async function olderConversationMessages(message: Message, client?: ApiCl
40
40
 
41
41
  /**
42
42
  * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
43
- * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
43
+ * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
44
+ * request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
44
45
  */
45
- export async function printableOlderMessage(message: Message): Promise<PrintableMessage> {
46
- const attachments = await listAttachments(message.folderUid, message.uid).catch(() => []);
46
+ export async function printableOlderMessage(message: Message, client?: ApiClient): Promise<PrintableMessage> {
47
+ const attachments = await listAttachments(message.folderUid, message.uid, client).catch(() => []);
47
48
  if (message.encrypted) {
48
49
  const unlocked = getUnlockedKeys(message.mailboxUid);
49
50
  if (unlocked) {
50
51
  const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
51
- const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid), unlocked);
52
+ const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid, client), unlocked);
52
53
  const content =
53
54
  security.text !== undefined ? ({ kind: "text", text: security.text } as const) : security.html !== undefined ? ({ kind: "html", html: security.html } as const) : undefined;
54
55
  if (content) {
@@ -70,7 +71,8 @@ export async function printableOlderMessage(message: Message): Promise<Printable
70
71
  return {
71
72
  subject: message.subject || "(no subject)",
72
73
  headers: printHeaders(message, formatMailAddress(message.from)),
73
- content: await fetchBodyContent(message.uid, message.version),
74
+ // Without a client this is exactly the cookie request it always was.
75
+ content: await (client ? fetchBodyContent(message.uid, message.version, undefined, client) : fetchBodyContent(message.uid, message.version)),
74
76
  attachments,
75
77
  };
76
78
  }
@@ -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);