@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.
- package/apps/escrow/matters/[uid].tsx +4 -3
- package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
- package/apps/shared/calendar/sealedEvents.ts +80 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +8 -4
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +23 -1
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +91 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
- package/apps/shared/components/mail/ConversationList.tsx +1 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +91 -20
- package/apps/shared/components/mail/compose/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/quotedBody.ts +31 -15
- package/apps/shared/components/mail/invite/inviteStore.ts +7 -1
- package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
- package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
- package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
- package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
- package/apps/shared/components/settings/KeyExportSetting.tsx +170 -0
- package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +117 -0
- package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +2 -2
- package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
- package/apps/shared/mail/reportNotices.ts +3 -1
- package/apps/shared/mail/zeroAccessSource.ts +27 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +31 -0
- package/apps/shared/search/localIndexBuilder.ts +27 -1
- package/apps/shared/search/localIndexRpcClient.ts +8 -4
- package/apps/shared/search/localIndexSchema.ts +2 -2
- package/apps/shared/search/localIndexTransport.ts +2 -1
- package/apps/shared/search/localIndexWorker.ts +6 -4
- package/apps/shared/search/zeroAccessIndexSync.ts +487 -0
- package/apps/www/calendar/index.tsx +180 -58
- package/apps/www/contacts/index.tsx +58 -2
- package/apps/www/index.tsx +10 -3
- package/apps/www/settings/encryption/index.tsx +5 -0
- package/apps/www/settings/privacy/index.tsx +2 -1
- package/apps/www/tasks/index.tsx +42 -0
- package/dist/apps/escrow/matters/[uid].js +2 -2
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
- package/dist/apps/shared/calendar/sealedEvents.d.ts +13 -0
- package/dist/apps/shared/calendar/sealedEvents.js +54 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +18 -2
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +21 -2
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +15 -0
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +72 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +73 -17
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +4 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -15
- package/dist/apps/shared/components/mail/invite/inviteStore.js +6 -1
- package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
- package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
- package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
- package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/KeyExportSetting.js +88 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +14 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +71 -0
- package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
- package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
- package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
- package/dist/apps/shared/mail/decryptedMessages.js +11 -3
- package/dist/apps/shared/mail/outbox/sendJob.js +2 -2
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
- package/dist/apps/shared/mail/reportNotices.js +3 -1
- package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
- package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +28 -0
- package/dist/apps/shared/search/localIndexBuilder.d.ts +5 -0
- package/dist/apps/shared/search/localIndexBuilder.js +24 -2
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +1 -1
- package/dist/apps/shared/search/localIndexRpcClient.js +6 -4
- package/dist/apps/shared/search/localIndexSchema.d.ts +1 -1
- package/dist/apps/shared/search/localIndexSchema.js +2 -2
- package/dist/apps/shared/search/localIndexTransport.d.ts +2 -1
- package/dist/apps/shared/search/localIndexWorker.d.ts +2 -0
- package/dist/apps/shared/search/localIndexWorker.js +4 -4
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +71 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.js +382 -0
- package/dist/apps/www/calendar/index.js +48 -22
- package/dist/apps/www/contacts/index.js +49 -3
- package/dist/apps/www/index.js +10 -3
- package/dist/apps/www/settings/encryption/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +1 -1
- package/dist/apps/www/tasks/index.js +29 -1
- package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
- package/dist/lib/calendar/calendarApi.d.ts +6 -0
- package/dist/lib/calendar/inviteApi.js +27 -5
- package/dist/lib/calendar/sealedInvites.d.ts +11 -0
- package/dist/lib/calendar/sealedInvites.js +28 -0
- package/dist/lib/crypto/keyExport.d.ts +54 -0
- package/dist/lib/crypto/keyExport.js +161 -0
- package/dist/lib/crypto/keySession.d.ts +45 -1
- package/dist/lib/crypto/keySession.js +93 -2
- package/dist/lib/crypto/keyvaultApi.d.ts +88 -3
- package/dist/lib/crypto/keyvaultApi.js +32 -0
- package/dist/lib/crypto/messageSecurity.d.ts +30 -1
- package/dist/lib/crypto/messageSecurity.js +80 -4
- package/dist/lib/crypto/mime.d.ts +5 -0
- package/dist/lib/crypto/mime.js +25 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +50 -0
- package/dist/lib/crypto/sharedMailboxKeys.js +264 -0
- package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
- package/dist/lib/crypto/zeroAccessKey.js +66 -0
- package/dist/lib/mail/conversationsApi.d.ts +6 -0
- package/dist/lib/mail/mailApi.d.ts +30 -2
- package/dist/lib/mail/mailApi.js +5 -1
- package/dist/lib/mail/pushClient.d.ts +10 -3
- package/dist/lib/mail/pushClient.js +18 -4
- package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -0
- package/dist/lib/tasks/tasksApi.d.ts +1 -0
- package/dist/lib/tasks/tasksApi.js +3 -0
- package/lib/admin/escrowAccessRequestsApi.ts +6 -0
- package/lib/calendar/calendarApi.ts +3 -0
- package/lib/calendar/inviteApi.ts +28 -5
- package/lib/calendar/sealedInvites.ts +33 -0
- package/lib/crypto/keyExport.ts +229 -0
- package/lib/crypto/keySession.ts +117 -3
- package/lib/crypto/keyvaultApi.ts +125 -3
- package/lib/crypto/messageSecurity.ts +110 -3
- package/lib/crypto/mime.ts +26 -0
- package/lib/crypto/sharedMailboxKeys.ts +324 -0
- package/lib/crypto/zeroAccessKey.ts +97 -0
- package/lib/mail/conversationsApi.ts +3 -0
- package/lib/mail/mailApi.ts +33 -3
- package/lib/mail/pushClient.ts +18 -4
- package/lib/search/zeroAccessIndexApi.ts +119 -0
- package/lib/tasks/tasksApi.ts +4 -0
- 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
|
-
|
|
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 =
|
|
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 ||
|
|
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
|
-
|
|
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={() =>
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|