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