@rapidmx/web-client 0.3.1 → 0.5.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/admin/audit-log/index.tsx +35 -8
- package/apps/admin/branding/index.tsx +39 -404
- package/apps/admin/data-requests/index.tsx +480 -485
- package/apps/admin/distribution-lists/[uid].tsx +19 -1
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/escrow-scopes/[uid].tsx +348 -237
- package/apps/admin/escrow-scopes/new/index.tsx +8 -2
- package/apps/admin/index.tsx +118 -82
- package/apps/admin/ingest-queue/index.tsx +44 -5
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/[uid].tsx +211 -184
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/quarantine/index.tsx +46 -14
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/admin/transport-rules/[uid].tsx +30 -1
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
- package/apps/admin/transport-rules/index.tsx +24 -6
- package/apps/admin/transport-rules/new/index.tsx +30 -1
- package/apps/escrow/_layout.tsx +2 -3
- package/apps/escrow/audit-log/index.tsx +196 -168
- package/apps/escrow/matters/[uid].tsx +621 -521
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
- package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
- package/apps/shared/components/admin/signOut.ts +79 -0
- package/apps/shared/components/admin/usePagedList.tsx +129 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +717 -513
- package/apps/shared/components/calendar/MonthView.tsx +173 -139
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
- package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
- package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
- package/apps/shared/components/calendar/allDay.ts +124 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
- package/apps/shared/components/contacts/ContactForm.tsx +377 -314
- package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
- package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
- package/apps/shared/components/contacts/contactKeys.ts +78 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
- package/apps/shared/components/layout/AppShell.tsx +270 -169
- package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
- package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
- package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
- package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
- package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
- package/apps/shared/components/mail/pinnedSigners.ts +124 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -0
- package/apps/shared/components/mail/writableMailboxes.ts +101 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/mail/listAllPages.ts +39 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +316 -0
- package/apps/shared/search/localIndexSchema.ts +236 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexStorage.ts +98 -0
- package/apps/shared/search/localIndexVFS.ts +266 -0
- package/apps/shared/search/localIndexWorker.ts +926 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +438 -407
- package/apps/www/contacts/[uid].tsx +21 -2
- package/apps/www/contacts/index.tsx +148 -17
- package/apps/www/index.tsx +1314 -486
- package/apps/www/messages/[uid].tsx +23 -7
- package/apps/www/settings/auto-reply/index.tsx +134 -129
- package/apps/www/settings/booking-types/[uid].tsx +306 -300
- package/apps/www/settings/encryption/index.tsx +800 -215
- package/apps/www/settings/filters/[uid].tsx +171 -155
- package/apps/www/settings/filters/new/index.tsx +8 -1
- package/apps/www/settings/focused-inbox/index.tsx +150 -148
- package/apps/www/settings/privacy/index.tsx +494 -436
- package/apps/www/settings/read-receipts/index.tsx +148 -125
- package/apps/www/settings/sharing/index.tsx +274 -0
- package/apps/www/tasks/index.tsx +638 -530
- package/dist/apps/admin/audit-log/index.js +38 -8
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
- package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
- package/dist/apps/admin/index.js +17 -1
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/quarantine/index.js +20 -11
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/admin/transport-rules/[uid].js +18 -2
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
- package/dist/apps/admin/transport-rules/index.js +22 -6
- package/dist/apps/admin/transport-rules/new/index.js +18 -2
- package/dist/apps/escrow/_layout.js +3 -3
- package/dist/apps/escrow/audit-log/index.js +26 -1
- package/dist/apps/escrow/matters/[uid].js +77 -42
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
- package/dist/apps/shared/components/admin/signOut.js +75 -0
- package/dist/apps/shared/components/admin/usePagedList.js +99 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +154 -21
- package/dist/apps/shared/components/calendar/MonthView.js +12 -5
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
- package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
- package/dist/apps/shared/components/calendar/allDay.js +111 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
- package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
- package/dist/apps/shared/components/layout/AppShell.js +100 -8
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
- package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
- package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
- package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/mail/listAllPages.js +25 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +408 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
- package/dist/apps/shared/search/localIndexSchema.js +185 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.js +258 -0
- package/dist/apps/shared/search/localIndexWorker.js +687 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +38 -18
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.js +94 -15
- package/dist/apps/www/index.js +770 -137
- package/dist/apps/www/messages/[uid].js +23 -7
- package/dist/apps/www/settings/auto-reply/index.js +9 -4
- package/dist/apps/www/settings/booking-types/[uid].js +17 -12
- package/dist/apps/www/settings/encryption/index.js +541 -96
- package/dist/apps/www/settings/filters/[uid].js +22 -7
- package/dist/apps/www/settings/filters/new/index.js +8 -2
- package/dist/apps/www/settings/focused-inbox/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +57 -22
- package/dist/apps/www/settings/read-receipts/index.js +10 -3
- package/dist/apps/www/settings/sharing/index.js +124 -0
- package/dist/apps/www/tasks/index.js +76 -14
- package/package.json +3 -2
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { useEffect, useState } from "react";
|
|
5
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
6
|
+
import { HiOutlineCheck, HiOutlineExclamationTriangle, HiOutlineLockClosed } from "react-icons/hi2";
|
|
6
7
|
import DOMPurify from "dompurify";
|
|
7
8
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
9
|
import {
|
|
@@ -16,16 +17,32 @@ import {
|
|
|
16
17
|
cancelScheduledSend,
|
|
17
18
|
classifyMessage,
|
|
18
19
|
declineReceipt,
|
|
20
|
+
getMessage,
|
|
19
21
|
getMessageRawContent,
|
|
20
22
|
recallMessage,
|
|
21
23
|
setMessageLabels,
|
|
22
24
|
} from "@rapidmx/react-shared/mail/mailApi.js";
|
|
23
25
|
import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
24
26
|
import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
25
|
-
import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
26
|
-
import {
|
|
27
|
+
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
28
|
+
import {
|
|
29
|
+
MessageSecurityResult,
|
|
30
|
+
SignatureFailureReason,
|
|
31
|
+
evaluateMessageSecurity,
|
|
32
|
+
evaluateMessageSecurityWithSeal,
|
|
33
|
+
} from "@rapidmx/react-shared/crypto/messageSecurity.js";
|
|
34
|
+
import { extractAddresses, type MimeAttachment } from "@rapidmx/react-shared/crypto/mime.js";
|
|
35
|
+
import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
27
36
|
import { isLikelyMailingList } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
37
|
+
import { SenderKeyState, clearPinnedSignerCache, getPinnedSignerFingerprints, getSignerKeyState } from "./pinnedSigners.js";
|
|
38
|
+
import { currentVerificationSeal, getVaultGeneration, sendVerificationSeal } from "./verificationSeals.js";
|
|
39
|
+
import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
|
|
40
|
+
import KeyChangeReview from "../contacts/KeyChangeReview.js";
|
|
41
|
+
import { KEY_CHANGE_STALE_MESSAGE, sameFingerprint } from "../contacts/contactKeys.js";
|
|
28
42
|
import { useCompose } from "./compose/ComposeContext.js";
|
|
43
|
+
import { useMailShell } from "./layout/MailShell.js";
|
|
44
|
+
import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
|
|
45
|
+
import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
|
|
29
46
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
30
47
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
31
48
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
@@ -40,12 +57,203 @@ const SECURITY_INDICATOR: Record<MessageSecurityResult["state"], { label: string
|
|
|
40
57
|
encrypted: { label: "Encrypted", className: "bg-primary/10 text-primary-dark" },
|
|
41
58
|
signed_verified: { label: "Signed & verified", className: "bg-success/10 text-success" },
|
|
42
59
|
encrypted_verified: { label: "Encrypted & verified", className: "bg-success/10 text-success" },
|
|
60
|
+
// A valid signature from a certificate the reader hasn't pinned - anyone can mint a certificate naming the
|
|
61
|
+
// sender, so this must never look like the verified states.
|
|
62
|
+
signed_unverified_signer: { label: "Signed - signer not verified", className: "bg-warning/15 text-text" },
|
|
63
|
+
encrypted_unverified_signer: { label: "Encrypted - signer not verified", className: "bg-warning/15 text-text" },
|
|
43
64
|
signature_failed: { label: "Signature failed", className: "bg-danger-bg text-danger" },
|
|
65
|
+
// Verified when first opened (a verification seal), but not live: muted, never the green verified pill. A signer key
|
|
66
|
+
// later reported compromised switches it to `LATER_COMPROMISED_INDICATOR_CLASS`.
|
|
67
|
+
verified_at_first_open: { label: "Verified when first opened", className: "bg-surface-alt text-text-muted" },
|
|
44
68
|
};
|
|
45
69
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
/** The amber `verified_at_first_open` badge for a signer key since reported compromised. */
|
|
71
|
+
const LATER_COMPROMISED_INDICATOR_CLASS = "bg-warning/15 text-text";
|
|
72
|
+
|
|
73
|
+
/** Whether a result is (or, for `verified_at_first_open`, was live) a `signer_key_changed` failure. */
|
|
74
|
+
function signerKeyChanged(result: MessageSecurityResult): boolean {
|
|
75
|
+
return result.signatureFailureReason === "signer_key_changed" || result.liveSignatureFailureReason === "signer_key_changed";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The detail line under a `verified_at_first_open` badge. */
|
|
79
|
+
export function verifiedAtFirstOpenMessage(result: MessageSecurityResult): string {
|
|
80
|
+
const date = new Date(result.verifiedAt!).toLocaleDateString();
|
|
81
|
+
if (result.laterCompromised) {
|
|
82
|
+
return `This signature was verified on ${date}, but the sender's key was later reported compromised; treat this message with caution.`;
|
|
83
|
+
}
|
|
84
|
+
if (result.liveSignatureFailureReason === "signer_key_changed") {
|
|
85
|
+
return `This signature was verified on ${date}. The sender has since started signing with a different key.`;
|
|
86
|
+
}
|
|
87
|
+
return `This signature was verified on ${date}. The sender's key is no longer trusted since then, for example because it was removed, replaced or revoked.`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const VERIFIED_STATES = new Set<MessageSecurityResult["state"]>(["signed_verified", "encrypted_verified"]);
|
|
91
|
+
const UNVERIFIED_SIGNER_STATES = new Set<MessageSecurityResult["state"]>(["signed_unverified_signer", "encrypted_unverified_signer"]);
|
|
92
|
+
|
|
93
|
+
/** An `@`, or a look-alike a reader would take for one (fullwidth, small) - restapi's own `AT_SIGN_LIKE`. */
|
|
94
|
+
const AT_SIGN_LIKE = /[@\uFF20\uFE6B]/;
|
|
95
|
+
|
|
96
|
+
export interface SenderNameCheck {
|
|
97
|
+
/** The display name contains an `@` or a look-alike - it reads as (part of) an address. */
|
|
98
|
+
looksLikeAddress: boolean;
|
|
99
|
+
/** It shows an address-shaped token that isn't `actualAddress` - e.g. `"ceo@corp.com" <x@corp-pay.com>`. */
|
|
100
|
+
misleading: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether a sender's display name poses as an address, and whether that address differs from the one actually sent
|
|
104
|
+
* from. Compatibility forms are folded first (NFKC turns the fullwidth and small @ into `@`), so a look-alike can't
|
|
105
|
+
* hide the token. */
|
|
106
|
+
export function checkSenderName(displayName: string | undefined, actualAddress: string): SenderNameCheck {
|
|
107
|
+
if (!displayName || !AT_SIGN_LIKE.test(displayName)) {
|
|
108
|
+
return { looksLikeAddress: false, misleading: false };
|
|
109
|
+
}
|
|
110
|
+
const shown = /[^\s<>"'(),;:]+@[^\s<>"'(),;:]+/.exec(displayName.normalize("NFKC"))?.[0];
|
|
111
|
+
return { looksLikeAddress: true, misleading: shown !== undefined && shown.toLowerCase() !== actualAddress.toLowerCase() };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const TRUST_SIGNER_CONFLICT_MESSAGE =
|
|
115
|
+
"A different signing key is already trusted for this sender, so this one wasn't trusted. The sender's key may have changed; confirm with them before doing anything.";
|
|
116
|
+
export const TRUST_SIGNER_INVALID_MESSAGE = "This certificate can't be trusted for this sender.";
|
|
117
|
+
export const TRUST_SIGNER_FORBIDDEN_MESSAGE = "You don't have permission to trust signers for this mailbox.";
|
|
118
|
+
export const TRUST_SIGNER_GENERIC_MESSAGE = "Couldn't trust this signer. Try again.";
|
|
119
|
+
export const KEPT_CURRENT_SIGNING_KEY_MESSAGE = "You kept the current signing key for this sender. This message stays unverified.";
|
|
120
|
+
|
|
121
|
+
/** The error text for a failed `trustSigner()`. */
|
|
122
|
+
export function trustSignerErrorMessage(err: unknown): string {
|
|
123
|
+
if (err instanceof SignerKeyConflictError) {
|
|
124
|
+
return TRUST_SIGNER_CONFLICT_MESSAGE;
|
|
125
|
+
}
|
|
126
|
+
if (err instanceof ApiRequestError && err.status === 400) {
|
|
127
|
+
return TRUST_SIGNER_INVALID_MESSAGE;
|
|
128
|
+
}
|
|
129
|
+
if (err instanceof ApiRequestError && err.status === 403) {
|
|
130
|
+
return TRUST_SIGNER_FORBIDDEN_MESSAGE;
|
|
131
|
+
}
|
|
132
|
+
return TRUST_SIGNER_GENERIC_MESSAGE;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Groups a fingerprint into 4-character blocks (`AB12 CD34 ...`), ignoring any separators it came with, so it can be
|
|
136
|
+
* read out and compared with the sender over another channel. */
|
|
137
|
+
export function formatFingerprint(fingerprint: string): string {
|
|
138
|
+
const compact = fingerprint.replace(/[\s:]/g, "").toUpperCase();
|
|
139
|
+
return compact.match(/.{1,4}/g)?.join(" ") ?? compact;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
|
|
143
|
+
* opaque download (`application/octet-stream`), never rendered in this origin. */
|
|
144
|
+
function downloadMimeAttachment(attachment: MimeAttachment): void {
|
|
145
|
+
// `decode()` is `undefined` only for invalid base64 - an empty file is the honest result then.
|
|
146
|
+
const bytes = attachment.decode() ?? new Uint8Array(0);
|
|
147
|
+
const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: "application/octet-stream" }));
|
|
148
|
+
const a = document.createElement("a");
|
|
149
|
+
a.href = url;
|
|
150
|
+
a.download = attachment.filename ?? "attachment";
|
|
151
|
+
document.body.appendChild(a);
|
|
152
|
+
a.click();
|
|
153
|
+
a.remove();
|
|
154
|
+
// Some browsers are still reading the blob after `click()` returns.
|
|
155
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function SecurityIndicator({ security }: { security: MessageSecurityResult }) {
|
|
159
|
+
const { label, className } = SECURITY_INDICATOR[security.state];
|
|
160
|
+
if (security.state !== "verified_at_first_open") {
|
|
161
|
+
return <span className={`text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`}>{label}</span>;
|
|
162
|
+
}
|
|
163
|
+
return (
|
|
164
|
+
<span
|
|
165
|
+
className={`inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${
|
|
166
|
+
security.laterCompromised ? LATER_COMPROMISED_INDICATOR_CLASS : className
|
|
167
|
+
}`}
|
|
168
|
+
>
|
|
169
|
+
{security.laterCompromised ? (
|
|
170
|
+
<HiOutlineExclamationTriangle size={12} aria-hidden="true" />
|
|
171
|
+
) : (
|
|
172
|
+
<HiOutlineCheck size={12} aria-hidden="true" />
|
|
173
|
+
)}
|
|
174
|
+
{label}
|
|
175
|
+
</span>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** User-facing explanation for each `signatureFailureReason` - shown alongside the "Signature failed"
|
|
180
|
+
* badge so an unverifiable (e.g. foreign/unparseable) signed message reads as "not verified, and here's
|
|
181
|
+
* why" rather than as a bare error. The body itself stays visible either way. */
|
|
182
|
+
const SIGNATURE_FAILURE_MESSAGE: Record<SignatureFailureReason, string> = {
|
|
183
|
+
invalid_signature:
|
|
184
|
+
"This message's digital signature couldn't be verified - it may be malformed, use an unsupported format, or the content may have been altered after signing. Treat it as unverified.",
|
|
185
|
+
untrusted_signer: "This message was signed with a certificate that doesn't match the sender's known key. Treat it as unverified.",
|
|
186
|
+
// Never shown: the dedicated "signing key changed" notice replaces it. Present so every reason has copy.
|
|
187
|
+
signer_key_changed: "This message was signed with a different key than the one you trust for this sender. Treat it as unverified.",
|
|
188
|
+
signer_identity_mismatch:
|
|
189
|
+
"This message's signing certificate doesn't belong to the sender shown in From. Treat it as unverified.",
|
|
190
|
+
header_mismatch:
|
|
191
|
+
"The sender/recipients this message was signed with don't match its visible From/To, or it repeats a From, To, Cc or Sender header. Treat it as unverified.",
|
|
192
|
+
};
|
|
193
|
+
const GENERIC_SIGNATURE_FAILURE_MESSAGE = "This message's digital signature couldn't be verified. Treat it as unverified.";
|
|
194
|
+
|
|
195
|
+
/** Fallback when the raw MIME for an encrypted message can't even be fetched/evaluated - an encrypted
|
|
196
|
+
* message must never be mislabeled "Unprotected" just because loading failed. */
|
|
197
|
+
const ENCRYPTED_LOAD_ERROR = "Couldn't load this message's encrypted content.";
|
|
198
|
+
|
|
199
|
+
/** Content Security Policy prepended to every client-rendered (decrypted/verified) body's `srcDoc` - the
|
|
200
|
+
* backstop behind the sanitizer below: nothing in the document may load any remote resource at all, only
|
|
201
|
+
* inline styles and `data:`/`cid:` images. */
|
|
202
|
+
const BODY_CSP_META =
|
|
203
|
+
"<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; img-src data: cid:; style-src 'unsafe-inline'\">";
|
|
204
|
+
|
|
205
|
+
const EMBEDDED_URI = /^\s*(?:data|cid):/i;
|
|
206
|
+
/** Attributes whose value makes the browser fetch a resource (as opposed to a user-clicked link). */
|
|
207
|
+
const RESOURCE_URI_ATTRIBUTES = new Set(["src", "srcset", "background", "poster", "lowsrc", "dynsrc", "xlink:href", "action", "formaction"]);
|
|
208
|
+
|
|
209
|
+
/** Decodes CSS escapes first (so an escaped `u\72l(` can't hide from the checks below), then drops every
|
|
210
|
+
* `@import` and neutralizes every `url()`/`image-set()` reference that isn't a `data:`/`cid:` URI. */
|
|
211
|
+
export function stripRemoteCssUrls(css: string): string {
|
|
212
|
+
return css
|
|
213
|
+
.replace(/\\([0-9a-f]{1,6})\s?/gi, (_match, hex: string) => String.fromCodePoint(Math.min(parseInt(hex, 16), 0x10ffff)))
|
|
214
|
+
.replace(/\\(.)/g, "$1")
|
|
215
|
+
.replace(/@import[^;]*;?/gi, "")
|
|
216
|
+
.replace(/(?:-webkit-)?image-set\((?:[^()]|\([^()]*\))*\)/gi, (match) =>
|
|
217
|
+
[...match.matchAll(/(["'])(.*?)\1/g)].every(([, , uri]) => EMBEDDED_URI.test(uri)) ? match : "none",
|
|
218
|
+
)
|
|
219
|
+
.replace(/url\(\s*(["']?)(.*?)\1\s*\)/gi, (match, _quote: string, uri: string) => (EMBEDDED_URI.test(uri) ? match : "none"));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let bodyPurifier: ReturnType<typeof DOMPurify> | undefined;
|
|
223
|
+
|
|
224
|
+
/** A dedicated DOMPurify instance (hooks registered here never leak into any other DOMPurify caller)
|
|
225
|
+
* that additionally strips every remote resource reference - see `stripRemoteCssUrls()`. */
|
|
226
|
+
function getBodyPurifier(): ReturnType<typeof DOMPurify> {
|
|
227
|
+
if (!bodyPurifier) {
|
|
228
|
+
bodyPurifier = DOMPurify(window);
|
|
229
|
+
bodyPurifier.addHook("uponSanitizeElement", (node, data) => {
|
|
230
|
+
if (data.tagName === "style") {
|
|
231
|
+
// An element's `textContent` is always a string (only documents/doctypes yield null).
|
|
232
|
+
node.textContent = stripRemoteCssUrls(node.textContent as string);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
bodyPurifier.addHook("uponSanitizeAttribute", (node, data) => {
|
|
236
|
+
const name = data.attrName.toLowerCase();
|
|
237
|
+
if (name === "style") {
|
|
238
|
+
data.attrValue = stripRemoteCssUrls(data.attrValue);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const isNavigationLink = name === "href" && ["a", "area"].includes(node.nodeName.toLowerCase());
|
|
242
|
+
if (RESOURCE_URI_ATTRIBUTES.has(name) || (name === "href" && !isNavigationLink)) {
|
|
243
|
+
const candidates = name === "srcset" ? data.attrValue.split(/,\s+/) : [data.attrValue];
|
|
244
|
+
if (!candidates.every((candidate) => EMBEDDED_URI.test(candidate))) {
|
|
245
|
+
data.keepAttr = false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return bodyPurifier;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Sanitizes a client-rendered body for `srcDoc`, with the CSP meta as its very first element. */
|
|
254
|
+
export function buildSecureSrcDoc(html: string): string {
|
|
255
|
+
const sanitized = getBodyPurifier().sanitize(html, { FORBID_TAGS: ["link", "meta", "base"] });
|
|
256
|
+
return BODY_CSP_META + sanitized;
|
|
49
257
|
}
|
|
50
258
|
|
|
51
259
|
export interface MessageDetailPaneProps {
|
|
@@ -116,8 +324,26 @@ function formatBytes(bytes: number): string {
|
|
|
116
324
|
* a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
|
|
117
325
|
* site for how `message`/`attachments`/`isSentItems` are sourced.
|
|
118
326
|
*/
|
|
119
|
-
export default function MessageDetailPane({
|
|
120
|
-
message
|
|
327
|
+
export default function MessageDetailPane(props: MessageDetailPaneProps) {
|
|
328
|
+
if (!props.message) {
|
|
329
|
+
return <p className="p-8 text-sm text-text-muted">Select a message to read it.</p>;
|
|
330
|
+
}
|
|
331
|
+
// Keyed by uid so every piece of per-message state below (security result, open modals, in-flight
|
|
332
|
+
// flags, errors) resets cleanly when a caller swaps `message` in place rather than remounting.
|
|
333
|
+
return <MessageDetailContent key={props.message.uid} {...props} message={props.message} />;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Whether a message's raw MIME has to be fetched to evaluate its security state. `encrypted` is the
|
|
337
|
+
* server's own classification; there is no server-side "signed" flag, but every S/MIME-signed shape
|
|
338
|
+
* (detached `multipart/signed`'s `smime.p7s`, opaque-signed `smime.p7m`) is stored with its CMS part
|
|
339
|
+
* surfaced as an attachment - so a message with neither flag can't carry a signature to verify, and
|
|
340
|
+
* fetching its full raw source (attachments included) would be pure waste. */
|
|
341
|
+
function needsRawSecurityEvaluation(message: Message): boolean {
|
|
342
|
+
return !!message.encrypted || message.hasAttachments;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function MessageDetailContent({
|
|
346
|
+
message: messageProp,
|
|
121
347
|
attachments,
|
|
122
348
|
backHref,
|
|
123
349
|
isSentItems,
|
|
@@ -131,7 +357,14 @@ export default function MessageDetailPane({
|
|
|
131
357
|
onArchived,
|
|
132
358
|
labels,
|
|
133
359
|
onLabelsChanged,
|
|
134
|
-
}: MessageDetailPaneProps) {
|
|
360
|
+
}: MessageDetailPaneProps & { message: Message }) {
|
|
361
|
+
// A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
|
|
362
|
+
// `reloadMessage()`. A prop copy newer by `version` wins; an equal-version reload is kept, since claiming a send
|
|
363
|
+
// lease need not bump `version`.
|
|
364
|
+
const [reloaded, setReloaded] = useState<Message | null>(null);
|
|
365
|
+
const message = reloaded && reloaded.version >= messageProp.version ? reloaded : messageProp;
|
|
366
|
+
// A reload that finds the message already moved on (sent, or moved to Drafts elsewhere) leaves Outbox.
|
|
367
|
+
const inOutbox = !!isOutbox && message.folderUid === messageProp.folderUid;
|
|
135
368
|
const { openCompose } = useCompose();
|
|
136
369
|
const [confirming, setConfirming] = useState(false);
|
|
137
370
|
const [recalling, setRecalling] = useState(false);
|
|
@@ -154,38 +387,230 @@ export default function MessageDetailPane({
|
|
|
154
387
|
const [receiptBusy, setReceiptBusy] = useState<ReceiptType | null>(null);
|
|
155
388
|
const [receiptError, setReceiptError] = useState<string | null>(null);
|
|
156
389
|
const [security, setSecurity] = useState<MessageSecurityResult | null>(null);
|
|
390
|
+
// Whether the pinned-signer lookup for the sender *succeeded* and found no signing keys - the only case "Trust this
|
|
391
|
+
// signer" is offered in. A failed lookup could be hiding an existing pin, and trusting never replaces one.
|
|
392
|
+
const [senderUnpinned, setSenderUnpinned] = useState(false);
|
|
393
|
+
const [trustConfirmOpen, setTrustConfirmOpen] = useState(false);
|
|
394
|
+
const [trusting, setTrusting] = useState(false);
|
|
395
|
+
const [trustError, setTrustError] = useState<string | null>(null);
|
|
396
|
+
// The sender's stored signing-key state, loaded for a `signer_key_changed` result (the comparison) and for an
|
|
397
|
+
// unverified signer with no pins (a recorded conflict sends the reader to the contact instead of offering trust).
|
|
398
|
+
// `undefined` when not needed or it couldn't be loaded.
|
|
399
|
+
const [senderKeyState, setSenderKeyState] = useState<SenderKeyState | undefined>(undefined);
|
|
400
|
+
// Whether the reader may update this mailbox (resolving a key change needs it) - `undefined` when unknown.
|
|
401
|
+
const [canUpdateMailbox, setCanUpdateMailbox] = useState<boolean | undefined>(undefined);
|
|
402
|
+
// "Keys changed while you were looking" after a 409, or the outcome of keeping the current key. Survives the
|
|
403
|
+
// re-evaluation that follows.
|
|
404
|
+
const [keyChangeNotice, setKeyChangeNotice] = useState<string | null>(null);
|
|
405
|
+
// "Now", for deciding whether a scheduled send's lease is still live - advanced when the lease runs out.
|
|
406
|
+
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
407
|
+
const { mailboxes } = useMailShell();
|
|
408
|
+
const { requestUnlock } = useUnlockPrompt();
|
|
409
|
+
// Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
|
|
410
|
+
// effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
|
|
411
|
+
// state; see keySession.ts's own doc comment).
|
|
412
|
+
const [unlockRefresh, setUnlockRefresh] = useState(0);
|
|
413
|
+
|
|
414
|
+
// The newest copy of this message known to the Labels popover - normally just the `message` prop,
|
|
415
|
+
// but a successful label toggle's server response is held here too, so a follow-up toggle always
|
|
416
|
+
// builds on the latest `labelUids`/`version` even before (or without) the caller patching its own
|
|
417
|
+
// state via `onLabelsChanged`. A prop copy at least as new (by `version`) always wins.
|
|
418
|
+
const [labelsMessage, setLabelsMessage] = useState<Message>(message);
|
|
419
|
+
const currentLabelsMessage = message.version >= labelsMessage.version ? message : labelsMessage;
|
|
420
|
+
const latestLabelsMessageRef = useRef<Message>(message);
|
|
421
|
+
const rawEvaluationNeeded = needsRawSecurityEvaluation(message);
|
|
422
|
+
// Every address the viewing mailbox receives at (primary first), as one string so the effect below only
|
|
423
|
+
// re-runs when they actually change.
|
|
424
|
+
const readerMailbox = mailboxes.find((mb) => mb.uid === message.mailboxUid);
|
|
425
|
+
const readerAddressesKey = readerMailbox ? [readerMailbox.primarySmtpAddress, ...(readerMailbox.aliasAddresses ?? [])].join(" ") : "";
|
|
157
426
|
|
|
158
|
-
// Evaluates
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
427
|
+
// Evaluates a message's security state from its raw MIME - only for a message that can actually be
|
|
428
|
+
// encrypted or signed (see `needsRawSecurityEvaluation()`); anything else is "Unprotected" without a
|
|
429
|
+
// fetch. A detached `multipart/signed` message needs its raw MIME read (a sanitized HTML body never
|
|
430
|
+
// carries the signature part) to tell "Signed & verified" apart from plain "Unprotected". A
|
|
431
|
+
// fetch/parse failure degrades to "Unprotected" (or, for a server-flagged encrypted message, to
|
|
432
|
+
// "Encrypted" with an explanation) rather than hiding the message.
|
|
433
|
+
//
|
|
434
|
+
// `getMessageRawContent()` takes no `AbortSignal`, so a superseded fetch can't be aborted - its
|
|
435
|
+
// result is ignored via `cancelled` instead (and never handed to `evaluateMessageSecurity()`).
|
|
163
436
|
useEffect(() => {
|
|
164
|
-
if (!
|
|
165
|
-
setSecurity(
|
|
437
|
+
if (!rawEvaluationNeeded) {
|
|
438
|
+
setSecurity({ state: "unprotected" });
|
|
166
439
|
return;
|
|
167
440
|
}
|
|
168
441
|
let cancelled = false;
|
|
169
442
|
setSecurity(null);
|
|
443
|
+
setSenderUnpinned(false);
|
|
444
|
+
setSenderKeyState(undefined);
|
|
445
|
+
// The sender's trusted signing keys: what key discovery pinned on this mailbox's contacts, plus this
|
|
446
|
+
// mailbox's own signing keys when it sent the message itself. A failed lookup means no pins, which can only
|
|
447
|
+
// ever make a signature "signer not verified", never verified.
|
|
448
|
+
const senderAddress = message.from.address;
|
|
449
|
+
const pinsPromise = getPinnedSignerFingerprints(message.mailboxUid, senderAddress).then(
|
|
450
|
+
(fingerprints) => ({ loaded: true, fingerprints }),
|
|
451
|
+
() => ({ loaded: false, fingerprints: [] as string[] }),
|
|
452
|
+
);
|
|
453
|
+
// Verification seals (see `verificationSeals.ts`) need unlocked keys and the vault's current master key
|
|
454
|
+
// generation; without either the message is evaluated exactly as before, with no seal. The sender's key records
|
|
455
|
+
// are loaded alongside, so a seal can say when the signer key was later revoked as compromised.
|
|
456
|
+
let keyStatePromise: Promise<SenderKeyState | undefined> | undefined;
|
|
457
|
+
const loadKeyState = () => (keyStatePromise ??= getSignerKeyState(message.mailboxUid, senderAddress).catch(() => undefined));
|
|
458
|
+
const sealContextPromise = getUnlockedKeys(message.mailboxUid)
|
|
459
|
+
? getVaultGeneration(message.mailboxUid).then(async (generation) =>
|
|
460
|
+
generation === undefined ? undefined : { generation, keyState: await loadKeyState() },
|
|
461
|
+
)
|
|
462
|
+
: undefined;
|
|
170
463
|
getMessageRawContent(message.uid)
|
|
171
|
-
.then((rawMime) =>
|
|
172
|
-
|
|
464
|
+
.then(async (rawMime) => {
|
|
465
|
+
if (cancelled) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const [primaryAddress, ...aliasAddresses] = readerAddressesKey ? readerAddressesKey.split(" ") : [];
|
|
469
|
+
const ownAddresses = readerAddressesKey.toLowerCase().split(" ");
|
|
470
|
+
const ownKeys = ownAddresses.includes(senderAddress.toLowerCase()) ? (readerMailbox!.keys ?? []) : [];
|
|
471
|
+
const ownPins = signingKeyFingerprints(ownKeys);
|
|
472
|
+
const [contactPins, sealContext] = await Promise.all([pinsPromise, sealContextPromise]);
|
|
473
|
+
const pins = [...new Set([...contactPins.fingerprints, ...ownPins])];
|
|
474
|
+
const pinned = pins.length > 0 ? pins : undefined;
|
|
475
|
+
// The raw content is a byte string - only ever handed to evaluateMessageSecurity(), never shown. Keys
|
|
476
|
+
// are read after the pin lookup's await, so a lock that happened meanwhile is honored.
|
|
477
|
+
const unlocked = getUnlockedKeys(message.mailboxUid);
|
|
478
|
+
let result: MessageSecurityResult;
|
|
479
|
+
if (sealContext && unlocked) {
|
|
480
|
+
const signerKeys = [
|
|
481
|
+
...(sealContext.keyState?.pinned ?? []),
|
|
482
|
+
...(sealContext.keyState?.previous ?? []),
|
|
483
|
+
...ownKeys,
|
|
484
|
+
];
|
|
485
|
+
result = await evaluateMessageSecurityWithSeal(rawMime, unlocked, pinned, primaryAddress, {
|
|
486
|
+
mailboxUid: message.mailboxUid,
|
|
487
|
+
messageUid: message.uid,
|
|
488
|
+
...currentVerificationSeal(message),
|
|
489
|
+
masterKeyGeneration: sealContext.generation,
|
|
490
|
+
signerKeys,
|
|
491
|
+
}).catch(() =>
|
|
492
|
+
// Only a lock while sealing throws: evaluate as locked (the lock itself re-evaluates too).
|
|
493
|
+
evaluateMessageSecurity(rawMime, undefined, pinned, primaryAddress),
|
|
494
|
+
);
|
|
495
|
+
if (result.sealToWrite) {
|
|
496
|
+
// Best effort and in the background: never awaited, never shown.
|
|
497
|
+
void sendVerificationSeal(message.uid, result.sealToWrite);
|
|
498
|
+
}
|
|
499
|
+
} else {
|
|
500
|
+
result = await evaluateMessageSecurity(rawMime, unlocked, pinned, primaryAddress);
|
|
501
|
+
}
|
|
502
|
+
// Only one reader address can be checked per evaluation: a message sent to one of this mailbox's
|
|
503
|
+
// aliases isn't "not addressed to you", so each alias is tried before saying so.
|
|
504
|
+
for (const alias of aliasAddresses) {
|
|
505
|
+
if (!result.notAddressedToReader) {
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
const viaAlias = await evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
|
|
509
|
+
result = { ...result, notAddressedToReader: viaAlias.notAddressedToReader };
|
|
510
|
+
}
|
|
511
|
+
const unpinned = contactPins.loaded && pins.length === 0;
|
|
512
|
+
const keyChanged = signerKeyChanged(result);
|
|
513
|
+
const [keyState, access] = await Promise.all([
|
|
514
|
+
keyChanged || (unpinned && UNVERIFIED_SIGNER_STATES.has(result.state)) ? loadKeyState() : undefined,
|
|
515
|
+
keyChanged ? getMyMailboxAccess(message.mailboxUid).then((a) => a.canUpdate, () => undefined) : undefined,
|
|
516
|
+
]);
|
|
173
517
|
if (!cancelled) {
|
|
174
518
|
setSecurity(result);
|
|
519
|
+
setSenderUnpinned(unpinned);
|
|
520
|
+
setSenderKeyState(keyState);
|
|
521
|
+
setCanUpdateMailbox(access);
|
|
175
522
|
}
|
|
176
523
|
})
|
|
177
524
|
.catch(() => {
|
|
178
525
|
if (!cancelled) {
|
|
179
|
-
setSecurity(
|
|
526
|
+
setSecurity(
|
|
527
|
+
message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
|
|
528
|
+
);
|
|
180
529
|
}
|
|
181
530
|
});
|
|
182
531
|
return () => {
|
|
183
532
|
cancelled = true;
|
|
184
533
|
};
|
|
185
|
-
}, [message
|
|
534
|
+
}, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey]);
|
|
186
535
|
|
|
187
|
-
|
|
188
|
-
|
|
536
|
+
// Decrypted plaintext must not outlive the key session that produced it: the moment this mailbox's
|
|
537
|
+
// keys are destroyed (logout, idle timeout, explicit lock), drop the recovered html/text and
|
|
538
|
+
// re-evaluate - an encrypted message then falls back to its "Unlock to view" state.
|
|
539
|
+
useEffect(
|
|
540
|
+
() =>
|
|
541
|
+
subscribeKeySession((event) => {
|
|
542
|
+
if (event.mailboxUid === message.mailboxUid && event.state === "locked") {
|
|
543
|
+
setSecurity(null);
|
|
544
|
+
setUnlockRefresh((n) => n + 1);
|
|
545
|
+
}
|
|
546
|
+
}),
|
|
547
|
+
[message.mailboxUid],
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
// While restapi's ScheduledSendJob holds a send lease the message is being relayed: cancelling or moving it would be
|
|
551
|
+
// refused (409/403), so the Outbox controls are replaced by "Sending...". When the lease runs out the message is
|
|
552
|
+
// re-read - it has either been sent (and left Outbox) or been released back to its schedule.
|
|
553
|
+
const leaseExpiresMs = message.scheduledSendLeaseExpiresAt ? Date.parse(message.scheduledSendLeaseExpiresAt) : NaN;
|
|
554
|
+
const sendInProgress = inOutbox && leaseExpiresMs > nowMs;
|
|
555
|
+
useEffect(() => {
|
|
556
|
+
if (!sendInProgress) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const timer = setTimeout(() => {
|
|
560
|
+
setNowMs(Date.now());
|
|
561
|
+
void reloadMessage();
|
|
562
|
+
}, leaseExpiresMs - nowMs + 1);
|
|
563
|
+
return () => clearTimeout(timer);
|
|
564
|
+
}, [sendInProgress, leaseExpiresMs, nowMs]);
|
|
565
|
+
|
|
566
|
+
async function reloadMessage() {
|
|
567
|
+
try {
|
|
568
|
+
setReloaded(await getMessage(message.uid));
|
|
569
|
+
setNowMs(Date.now());
|
|
570
|
+
} catch {
|
|
571
|
+
// Keep showing what we have - the next refused action or lease expiry tries again.
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Offered only when this device genuinely has no unlocked session for this message's mailbox at all
|
|
576
|
+
// (as opposed to being unlocked but still unable to decrypt - a wrong/since-rotated key, which
|
|
577
|
+
// re-unlocking the same session can't fix) - see `evaluateMessageSecurity()`'s own doc comment on why
|
|
578
|
+
// `decryptError` alone can't distinguish those two cases.
|
|
579
|
+
async function handleUnlockToView() {
|
|
580
|
+
const mailboxUid = message.mailboxUid;
|
|
581
|
+
const mailboxKeys = mailboxes.find((mb) => mb.uid === mailboxUid)?.keys ?? [];
|
|
582
|
+
try {
|
|
583
|
+
await requestUnlock(mailboxUid, mailboxKeys);
|
|
584
|
+
setUnlockRefresh((n) => n + 1);
|
|
585
|
+
} catch {
|
|
586
|
+
// User dismissed the unlock dialog - security state stays exactly as it was.
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Only ever invoked from the "Trust this signer" dialog, which only opens for an unverified-signer result carrying
|
|
591
|
+
// `signerCertificate`. The pin goes on the contact in the mailbox the message belongs to; the evaluation is then
|
|
592
|
+
// re-run against a fresh pinned-signer lookup, so the badge turns verified.
|
|
593
|
+
async function handleTrustSigner(address: string, certificate: string) {
|
|
594
|
+
setTrusting(true);
|
|
595
|
+
setTrustError(null);
|
|
596
|
+
try {
|
|
597
|
+
await trustSigner(message.mailboxUid, { address, certificate });
|
|
598
|
+
clearPinnedSignerCache();
|
|
599
|
+
setTrustConfirmOpen(false);
|
|
600
|
+
setUnlockRefresh((n) => n + 1);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
setTrustError(trustSignerErrorMessage(err));
|
|
603
|
+
} finally {
|
|
604
|
+
setTrusting(false);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// After a key change was accepted or kept (or the pinned key moved meanwhile): the pinned signers are re-read and
|
|
609
|
+
// the message re-evaluated, so an accepted key turns the badge verified.
|
|
610
|
+
function refreshAfterKeyChange(notice: string | null) {
|
|
611
|
+
setKeyChangeNotice(notice);
|
|
612
|
+
clearPinnedSignerCache();
|
|
613
|
+
setUnlockRefresh((n) => n + 1);
|
|
189
614
|
}
|
|
190
615
|
|
|
191
616
|
// Only ever invoked from the Reply/Reply All/Forward buttons below, which themselves only render
|
|
@@ -193,33 +618,33 @@ export default function MessageDetailPane({
|
|
|
193
618
|
// assertions reflect that real invariant, matching `handleRecall`'s identical pattern just below.
|
|
194
619
|
function handleReply() {
|
|
195
620
|
openCompose({
|
|
196
|
-
mailboxUid: message
|
|
197
|
-
to: message
|
|
198
|
-
subject: replySubject(message
|
|
199
|
-
quotedHtml: buildReplyQuote(message
|
|
621
|
+
mailboxUid: message.mailboxUid,
|
|
622
|
+
to: message.from.address,
|
|
623
|
+
subject: replySubject(message.subject),
|
|
624
|
+
quotedHtml: buildReplyQuote(message),
|
|
200
625
|
signatureContext: "reply_forward",
|
|
201
|
-
suppressSigning: isLikelyMailingList({ listUnsubscribe: message
|
|
626
|
+
suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
|
|
202
627
|
});
|
|
203
628
|
}
|
|
204
629
|
|
|
205
630
|
function handleReplyAll() {
|
|
206
|
-
const cc = message
|
|
631
|
+
const cc = message.recipients.filter((r) => r.type !== "bcc").map((r) => r.address);
|
|
207
632
|
openCompose({
|
|
208
|
-
mailboxUid: message
|
|
209
|
-
to: message
|
|
633
|
+
mailboxUid: message.mailboxUid,
|
|
634
|
+
to: message.from.address,
|
|
210
635
|
cc: cc.join(", "),
|
|
211
|
-
subject: replySubject(message
|
|
212
|
-
quotedHtml: buildReplyQuote(message
|
|
636
|
+
subject: replySubject(message.subject),
|
|
637
|
+
quotedHtml: buildReplyQuote(message),
|
|
213
638
|
signatureContext: "reply_forward",
|
|
214
|
-
suppressSigning: isLikelyMailingList({ listUnsubscribe: message
|
|
639
|
+
suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
|
|
215
640
|
});
|
|
216
641
|
}
|
|
217
642
|
|
|
218
643
|
function handleForward() {
|
|
219
644
|
openCompose({
|
|
220
|
-
mailboxUid: message
|
|
221
|
-
subject: forwardSubject(message
|
|
222
|
-
quotedHtml: buildForwardQuote(message
|
|
645
|
+
mailboxUid: message.mailboxUid,
|
|
646
|
+
subject: forwardSubject(message.subject),
|
|
647
|
+
quotedHtml: buildForwardQuote(message),
|
|
223
648
|
signatureContext: "reply_forward",
|
|
224
649
|
});
|
|
225
650
|
}
|
|
@@ -232,7 +657,7 @@ export default function MessageDetailPane({
|
|
|
232
657
|
setRecalling(true);
|
|
233
658
|
setError(null);
|
|
234
659
|
try {
|
|
235
|
-
const updated = await recallMessage(message
|
|
660
|
+
const updated = await recallMessage(message.uid);
|
|
236
661
|
setConfirming(false);
|
|
237
662
|
onRecalled?.(updated);
|
|
238
663
|
} catch (err) {
|
|
@@ -242,18 +667,26 @@ export default function MessageDetailPane({
|
|
|
242
667
|
}
|
|
243
668
|
}
|
|
244
669
|
|
|
245
|
-
// Only ever invoked from the "Cancel" button below
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
// a real invariant, matching `handleRecall`'s identical pattern just above.
|
|
670
|
+
// Only ever invoked from the "Cancel" button below (`isOutbox` and `message.scheduledSendTime` both truthy -
|
|
671
|
+
// `draftsFolderUid` is required whenever `isOutbox` is, see the prop's own doc comment), or from "Move to
|
|
672
|
+
// Drafts" (an Outbox message with no active schedule, which checks `draftsFolderUid` itself), so the non-null
|
|
673
|
+
// assertion reflects a real invariant, matching `handleRecall`'s identical pattern just above.
|
|
249
674
|
async function handleCancelScheduledSend() {
|
|
250
675
|
setCanceling(true);
|
|
251
676
|
setCancelError(null);
|
|
252
677
|
try {
|
|
253
|
-
const updated = await cancelScheduledSend(message
|
|
678
|
+
const updated = await cancelScheduledSend(message, draftsFolderUid!);
|
|
679
|
+
// Keeps a `folder:`-scoped Tier 2 local search from still finding it in its old folder.
|
|
680
|
+
void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
|
|
254
681
|
onScheduledSendCanceled?.(updated);
|
|
255
682
|
} catch (err) {
|
|
256
|
-
|
|
683
|
+
const fallback = message.scheduledSendTime ? "Could not cancel this scheduled send." : "Could not move this message to Drafts.";
|
|
684
|
+
setCancelError(err instanceof ApiRequestError ? err.message : fallback);
|
|
685
|
+
// A conflict or refusal usually means the send job claimed (or already relayed) the message meanwhile -
|
|
686
|
+
// re-read it so the pane shows "Sending..." or its new state instead of the stale controls.
|
|
687
|
+
if (err instanceof ApiRequestError && (err.status === 409 || err.status === 403)) {
|
|
688
|
+
void reloadMessage();
|
|
689
|
+
}
|
|
257
690
|
} finally {
|
|
258
691
|
setCanceling(false);
|
|
259
692
|
}
|
|
@@ -266,7 +699,8 @@ export default function MessageDetailPane({
|
|
|
266
699
|
setArchiving(true);
|
|
267
700
|
setArchiveError(null);
|
|
268
701
|
try {
|
|
269
|
-
const updated = await archiveMessage(message
|
|
702
|
+
const updated = await archiveMessage(message.uid);
|
|
703
|
+
void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
|
|
270
704
|
onArchived?.(updated);
|
|
271
705
|
} catch (err) {
|
|
272
706
|
setArchiveError(err instanceof ApiRequestError ? err.message : "Could not archive this message.");
|
|
@@ -279,13 +713,24 @@ export default function MessageDetailPane({
|
|
|
279
713
|
// `message` is loaded — auto-saves on every toggle (no separate "Save" step), computing the full
|
|
280
714
|
// new `labelUids` set from the message's current one since `setMessageLabels()` replaces the whole
|
|
281
715
|
// list rather than patching a single entry.
|
|
716
|
+
//
|
|
717
|
+
// Toggles are serialized (every checkbox is disabled while one is in flight - React flushes a
|
|
718
|
+
// discrete input event's state update synchronously, so a second change can't slip in first), and
|
|
719
|
+
// each one reads the latest known copy (`latestLabelsMessageRef`, never a stale render closure) - so
|
|
720
|
+
// two quick toggles can never compute their full list, or send their optimistic-lock `version`, from
|
|
721
|
+
// the same stale snapshot.
|
|
282
722
|
async function handleToggleLabel(labelUid: string) {
|
|
283
723
|
setTogglingLabelUid(labelUid);
|
|
284
724
|
setLabelsError(null);
|
|
285
725
|
try {
|
|
286
|
-
|
|
726
|
+
// Read through the ref (not this render's closure) so a toggle fired from a not-yet-re-rendered
|
|
727
|
+
// handler still sees the previous toggle's server response.
|
|
728
|
+
const base = message.version >= latestLabelsMessageRef.current.version ? message : latestLabelsMessageRef.current;
|
|
729
|
+
const current = base.labelUids ?? [];
|
|
287
730
|
const next = current.includes(labelUid) ? current.filter((uid) => uid !== labelUid) : [...current, labelUid];
|
|
288
|
-
const updated = await setMessageLabels(
|
|
731
|
+
const updated = await setMessageLabels(base, next);
|
|
732
|
+
latestLabelsMessageRef.current = updated;
|
|
733
|
+
setLabelsMessage(updated);
|
|
289
734
|
onLabelsChanged?.(updated);
|
|
290
735
|
} catch (err) {
|
|
291
736
|
setLabelsError(err instanceof ApiRequestError ? err.message : "Could not update this message's labels.");
|
|
@@ -301,7 +746,7 @@ export default function MessageDetailPane({
|
|
|
301
746
|
setClassifying(true);
|
|
302
747
|
setClassifyError(null);
|
|
303
748
|
try {
|
|
304
|
-
const updated = await classifyMessage(message
|
|
749
|
+
const updated = await classifyMessage(message.uid, classifyAs, alwaysForSender);
|
|
305
750
|
onClassified?.(updated);
|
|
306
751
|
} catch (err) {
|
|
307
752
|
setClassifyError(err instanceof ApiRequestError ? err.message : "Could not reclassify this message.");
|
|
@@ -316,7 +761,7 @@ export default function MessageDetailPane({
|
|
|
316
761
|
setReceiptBusy(type);
|
|
317
762
|
setReceiptError(null);
|
|
318
763
|
try {
|
|
319
|
-
const updated = await (action === "approve" ? approveReceipt : declineReceipt)(message
|
|
764
|
+
const updated = await (action === "approve" ? approveReceipt : declineReceipt)(message.uid, type);
|
|
320
765
|
onReceiptHandled?.(updated);
|
|
321
766
|
} catch (err) {
|
|
322
767
|
setReceiptError(err instanceof ApiRequestError ? err.message : "Could not handle this receipt request.");
|
|
@@ -325,6 +770,49 @@ export default function MessageDetailPane({
|
|
|
325
770
|
}
|
|
326
771
|
}
|
|
327
772
|
|
|
773
|
+
const verified = security !== null && VERIFIED_STATES.has(security.state);
|
|
774
|
+
// A seal proves the signature verified when first opened, and the live check still passed everything but the signer
|
|
775
|
+
// key's status - so the signed Subject and attachments are shown as for a verified message, under its own badge.
|
|
776
|
+
const verifiedOrSealed = verified || security?.state === "verified_at_first_open";
|
|
777
|
+
// Under a verified badge, the Subject shown is the one the signature covers (RFC 9788 protected headers), when
|
|
778
|
+
// the message carries one - the outer Subject is unsigned and anyone relaying the message could change it.
|
|
779
|
+
const protectedSubject = verifiedOrSealed ? security.protectedHeaders?.subject : undefined;
|
|
780
|
+
// Only meaningful for signed-only mail: an encrypted message's outer Subject is deliberately obscured. Not a
|
|
781
|
+
// signature failure (mailing lists legitimately tag subjects), just worth pointing out.
|
|
782
|
+
const subjectDiffers = protectedSubject !== undefined && (security!.state === "signed_verified" || security!.sealedState === "signed_verified") && protectedSubject !== message.subject;
|
|
783
|
+
// Attachments inside the signed/decrypted entity. Under a verified badge only these are listed - the server's
|
|
784
|
+
// attachment records also include parts outside the signature, which the badge doesn't vouch for. For decrypted
|
|
785
|
+
// mail the server only ever saw the encrypted blob, so these are the real attachments.
|
|
786
|
+
// A decrypted message whose signature failed still only has these (the server list is just `smime.p7m`); they're
|
|
787
|
+
// listed with a warning. A signed-only failure never carries recovered attachments.
|
|
788
|
+
const innerAttachments =
|
|
789
|
+
security?.attachments !== undefined &&
|
|
790
|
+
(verifiedOrSealed || security.state === "encrypted" || security.state === "encrypted_unverified_signer" || security.state === "signature_failed")
|
|
791
|
+
? security.attachments
|
|
792
|
+
: undefined;
|
|
793
|
+
// Any state that involves a signature shows the address actually signed for (the protected From when the message
|
|
794
|
+
// carries one - it equals the outer From's address, or verification would have failed) next to the badge, never a
|
|
795
|
+
// display name alone: anyone can put "ceo@corp.com" in the name of a message sent from x@corp-pay.com.
|
|
796
|
+
const signatureShown = security !== null && security.state !== "unprotected" && security.state !== "encrypted";
|
|
797
|
+
const senderAddress = (signatureShown ? extractAddresses(security.protectedHeaders?.from)[0] : undefined) ?? message.from.address;
|
|
798
|
+
const senderName = message.from.displayName;
|
|
799
|
+
const senderNameCheck = checkSenderName(senderName, senderAddress);
|
|
800
|
+
const showSenderAddress = !!senderName && (signatureShown || senderNameCheck.looksLikeAddress);
|
|
801
|
+
const senderLabel = showSenderAddress ? `${senderName} <${senderAddress}>` : senderName || senderAddress;
|
|
802
|
+
// Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
|
|
803
|
+
// A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
|
|
804
|
+
const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
|
|
805
|
+
const trustableCertificate =
|
|
806
|
+
security !== null && UNVERIFIED_SIGNER_STATES.has(security.state) && senderUnpinned && !pendingConflict
|
|
807
|
+
? security.signerCertificate
|
|
808
|
+
: undefined;
|
|
809
|
+
const keyChanged = security && signerKeyChanged(security) ? security : undefined;
|
|
810
|
+
const pinnedSignerKey = senderKeyState?.pinned[0];
|
|
811
|
+
const recordedConflict =
|
|
812
|
+
keyChanged && sameFingerprint(senderKeyState?.conflict?.observedKey.fingerprint, keyChanged.signerFingerprint)
|
|
813
|
+
? senderKeyState!.conflict
|
|
814
|
+
: undefined;
|
|
815
|
+
|
|
328
816
|
return (
|
|
329
817
|
<div className="flex-1 min-w-0 flex flex-col">
|
|
330
818
|
<div className="border-b border-border p-4">
|
|
@@ -335,9 +823,14 @@ export default function MessageDetailPane({
|
|
|
335
823
|
)}
|
|
336
824
|
<div className="flex items-start justify-between gap-3">
|
|
337
825
|
<div className="flex items-center gap-2 min-w-0">
|
|
338
|
-
<h1 className="text-lg font-bold tracking-tight truncate">{message.subject || "(no subject)"}</h1>
|
|
339
|
-
{security && <SecurityIndicator
|
|
826
|
+
<h1 className="text-lg font-bold tracking-tight truncate">{(protectedSubject ?? message.subject) || "(no subject)"}</h1>
|
|
827
|
+
{security && <SecurityIndicator security={security} />}
|
|
340
828
|
</div>
|
|
829
|
+
{sendInProgress && (
|
|
830
|
+
<span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
|
|
831
|
+
Sending…
|
|
832
|
+
</span>
|
|
833
|
+
)}
|
|
341
834
|
{isSentItems &&
|
|
342
835
|
(message.recallRequestedAt ? (
|
|
343
836
|
<span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
|
|
@@ -353,7 +846,7 @@ export default function MessageDetailPane({
|
|
|
353
846
|
Recall this message
|
|
354
847
|
</Button>
|
|
355
848
|
))}
|
|
356
|
-
{
|
|
849
|
+
{inOutbox && !sendInProgress && message.scheduledSendTime && (
|
|
357
850
|
<div className="flex items-center gap-2 shrink-0">
|
|
358
851
|
<span className="text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt">
|
|
359
852
|
Scheduled for {new Date(message.scheduledSendTime).toLocaleString()}
|
|
@@ -370,12 +863,90 @@ export default function MessageDetailPane({
|
|
|
370
863
|
</Button>
|
|
371
864
|
</div>
|
|
372
865
|
)}
|
|
866
|
+
{/* A message left in Outbox with no active schedule - a scheduled send that failed or was refused
|
|
867
|
+
(restapi's ScheduledSendJob clears scheduledSendTime and leaves it there), which can't be sent
|
|
868
|
+
again from Outbox (409) or archived - would otherwise be stuck. Moving it back to Drafts works for
|
|
869
|
+
any Outbox message. */}
|
|
870
|
+
{inOutbox && !sendInProgress && !message.scheduledSendTime && draftsFolderUid && (
|
|
871
|
+
<Button
|
|
872
|
+
type="button"
|
|
873
|
+
variant="secondary"
|
|
874
|
+
className="!w-auto shrink-0"
|
|
875
|
+
loading={canceling}
|
|
876
|
+
disabled={canceling}
|
|
877
|
+
onClick={handleCancelScheduledSend}
|
|
878
|
+
>
|
|
879
|
+
Move to Drafts
|
|
880
|
+
</Button>
|
|
881
|
+
)}
|
|
373
882
|
</div>
|
|
883
|
+
{inOutbox && message.scheduledSendError && (
|
|
884
|
+
<div className="mt-2">
|
|
885
|
+
<Alert>This message wasn’t sent: {message.scheduledSendError}</Alert>
|
|
886
|
+
</div>
|
|
887
|
+
)}
|
|
374
888
|
{cancelError && (
|
|
375
889
|
<div className="mt-2">
|
|
376
890
|
<Alert>{cancelError}</Alert>
|
|
377
891
|
</div>
|
|
378
892
|
)}
|
|
893
|
+
{keyChangeNotice && (
|
|
894
|
+
<p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
|
|
895
|
+
{keyChangeNotice}
|
|
896
|
+
</p>
|
|
897
|
+
)}
|
|
898
|
+
{security && UNVERIFIED_SIGNER_STATES.has(security.state) && (
|
|
899
|
+
// Informational: the signature is intact, but nothing ties its certificate to this sender - anyone can
|
|
900
|
+
// create a certificate naming any address. "Trust this signer" (below) pins it once the reader has
|
|
901
|
+
// confirmed the fingerprint.
|
|
902
|
+
<p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
|
|
903
|
+
Signed, but the signer isn’t a trusted contact key, so the sender isn’t verified.
|
|
904
|
+
{security.signerEmails && security.signerEmails.length > 0 && <> Certificate for {security.signerEmails.join(", ")}.</>}
|
|
905
|
+
{security.signerFingerprint && (
|
|
906
|
+
<>
|
|
907
|
+
{" "}
|
|
908
|
+
Fingerprint <span className="font-mono text-xs break-all">{security.signerFingerprint}</span>.
|
|
909
|
+
</>
|
|
910
|
+
)}
|
|
911
|
+
</p>
|
|
912
|
+
)}
|
|
913
|
+
{pendingConflict && (
|
|
914
|
+
// Only an unverified signer's result loads the key state while the sender is unpinned, and a conflict
|
|
915
|
+
// always comes from a matching contact, so its uid is known.
|
|
916
|
+
<p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
|
|
917
|
+
This sender has a signing key change waiting for your review, so this signer can’t be trusted from
|
|
918
|
+
here.{" "}
|
|
919
|
+
<a
|
|
920
|
+
href={`/contacts/${encodeURIComponent(senderKeyState.contactUid!)}`}
|
|
921
|
+
className="font-medium text-primary-dark hover:underline"
|
|
922
|
+
>
|
|
923
|
+
Review it in Contacts
|
|
924
|
+
</a>
|
|
925
|
+
</p>
|
|
926
|
+
)}
|
|
927
|
+
{trustableCertificate && (
|
|
928
|
+
<div className="mt-1.5">
|
|
929
|
+
<Button
|
|
930
|
+
type="button"
|
|
931
|
+
variant="secondary"
|
|
932
|
+
className="!w-auto"
|
|
933
|
+
disabled={trusting}
|
|
934
|
+
onClick={() => {
|
|
935
|
+
setTrustError(null);
|
|
936
|
+
setTrustConfirmOpen(true);
|
|
937
|
+
}}
|
|
938
|
+
>
|
|
939
|
+
Trust this signer
|
|
940
|
+
</Button>
|
|
941
|
+
</div>
|
|
942
|
+
)}
|
|
943
|
+
{subjectDiffers && (
|
|
944
|
+
<p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
|
|
945
|
+
The subject shown above is the one the sender signed. It differs from the subject this message was
|
|
946
|
+
delivered with (“{message.subject}”), which may have been changed on the way, e.g. by a
|
|
947
|
+
mailing list.
|
|
948
|
+
</p>
|
|
949
|
+
)}
|
|
379
950
|
{security?.headerTamperDetected && (
|
|
380
951
|
<div className="mt-2">
|
|
381
952
|
<Alert>
|
|
@@ -385,10 +956,76 @@ export default function MessageDetailPane({
|
|
|
385
956
|
</Alert>
|
|
386
957
|
</div>
|
|
387
958
|
)}
|
|
959
|
+
{security?.state === "verified_at_first_open" && (
|
|
960
|
+
// A seal outranks only a key-status failure (see `evaluateMessageSecurityWithSeal()`). A signer key
|
|
961
|
+
// later reported compromised is a warning, never reassurance.
|
|
962
|
+
<p
|
|
963
|
+
role="status"
|
|
964
|
+
className={`mt-2 py-2 px-3 rounded-sm text-sm text-text ${security.laterCompromised ? "bg-warning/15" : "bg-surface-alt"}`}
|
|
965
|
+
>
|
|
966
|
+
{verifiedAtFirstOpenMessage(security)}
|
|
967
|
+
</p>
|
|
968
|
+
)}
|
|
969
|
+
{keyChanged && (
|
|
970
|
+
<section aria-label="Signing key changed" className="mt-2 py-3 px-3 rounded-sm text-sm bg-warning/15 text-text flex flex-col gap-2">
|
|
971
|
+
<h2 className="font-semibold">This sender’s signing key changed</h2>
|
|
972
|
+
<p>
|
|
973
|
+
The signature on this message is valid and its certificate names {senderAddress}, but it was made
|
|
974
|
+
with a different key than the one you trust for this sender
|
|
975
|
+
{keyChanged.state === "verified_at_first_open" ? "." : <>, so it isn’t verified.</>}
|
|
976
|
+
</p>
|
|
977
|
+
<KeyChangeReview
|
|
978
|
+
mailboxUid={message.mailboxUid}
|
|
979
|
+
address={senderAddress}
|
|
980
|
+
useType="sign"
|
|
981
|
+
ownerName="the sender"
|
|
982
|
+
current={pinnedSignerKey && { fingerprint: pinnedSignerKey.fingerprint, since: senderKeyState.pinnedSince! }}
|
|
983
|
+
proposed={{
|
|
984
|
+
fingerprint: keyChanged.signerFingerprint!,
|
|
985
|
+
emails: keyChanged.signerEmails,
|
|
986
|
+
observedAt: recordedConflict?.observedAt,
|
|
987
|
+
source: recordedConflict?.source,
|
|
988
|
+
}}
|
|
989
|
+
certificate={keyChanged.signerCertificate}
|
|
990
|
+
canReject={recordedConflict !== undefined}
|
|
991
|
+
canResolve={canUpdateMailbox}
|
|
992
|
+
onResolved={(action) => refreshAfterKeyChange(action === "reject" ? KEPT_CURRENT_SIGNING_KEY_MESSAGE : null)}
|
|
993
|
+
onPinnedKeyChanged={() => refreshAfterKeyChange(KEY_CHANGE_STALE_MESSAGE)}
|
|
994
|
+
/>
|
|
995
|
+
</section>
|
|
996
|
+
)}
|
|
997
|
+
{security?.state === "signature_failed" && !keyChanged && (
|
|
998
|
+
// Deliberately an informational notice, not an error `Alert`: an unverifiable signature
|
|
999
|
+
// means "don't trust the signer", not "this message is broken" - the body stays readable.
|
|
1000
|
+
<p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
|
|
1001
|
+
{security.signatureFailureReason
|
|
1002
|
+
? SIGNATURE_FAILURE_MESSAGE[security.signatureFailureReason]
|
|
1003
|
+
: GENERIC_SIGNATURE_FAILURE_MESSAGE}
|
|
1004
|
+
</p>
|
|
1005
|
+
)}
|
|
1006
|
+
{verifiedOrSealed && !security.protectedHeaders && (
|
|
1007
|
+
// A legacy S/MIME sender signs only the body: the outer Subject/To/Cc shown here were never signed.
|
|
1008
|
+
<p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
|
|
1009
|
+
The signature covers this message’s content and attachments only. Its Subject, To and Cc
|
|
1010
|
+
weren’t signed, so they could have been changed after it was sent.
|
|
1011
|
+
</p>
|
|
1012
|
+
)}
|
|
1013
|
+
{security?.notAddressedToReader && (
|
|
1014
|
+
// Informational, like the notice above: a Bcc recipient legitimately sees this too.
|
|
1015
|
+
<p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
|
|
1016
|
+
The recipients this message was signed or encrypted for don’t include this mailbox - it may have
|
|
1017
|
+
been forwarded or re-sent to you unchanged, or you were Bcc’d.
|
|
1018
|
+
</p>
|
|
1019
|
+
)}
|
|
388
1020
|
<p className="text-sm text-text-muted mt-1">
|
|
389
|
-
From {
|
|
390
|
-
{new Date(message.receivedDate).toLocaleString()}
|
|
1021
|
+
From {senderLabel} · {new Date(message.receivedDate).toLocaleString()}
|
|
391
1022
|
</p>
|
|
1023
|
+
{senderNameCheck.misleading && (
|
|
1024
|
+
<p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
|
|
1025
|
+
The sender’s name “{senderName}” looks like an email address, but this message was
|
|
1026
|
+
sent from <span className="font-medium">{senderAddress}</span>. Don’t trust it based on the name.
|
|
1027
|
+
</p>
|
|
1028
|
+
)}
|
|
392
1029
|
<p className="text-sm text-text-muted">
|
|
393
1030
|
To {message.recipients.map((r) => r.displayName || r.address).join(", ")}
|
|
394
1031
|
</p>
|
|
@@ -402,7 +1039,7 @@ export default function MessageDetailPane({
|
|
|
402
1039
|
<Button type="button" variant="secondary" className="!w-auto" onClick={handleForward}>
|
|
403
1040
|
Forward
|
|
404
1041
|
</Button>
|
|
405
|
-
{!
|
|
1042
|
+
{!inOutbox && message.folderUid !== draftsFolderUid && (
|
|
406
1043
|
<Button
|
|
407
1044
|
type="button"
|
|
408
1045
|
variant="secondary"
|
|
@@ -425,9 +1062,9 @@ export default function MessageDetailPane({
|
|
|
425
1062
|
<Alert>{archiveError}</Alert>
|
|
426
1063
|
</div>
|
|
427
1064
|
)}
|
|
428
|
-
{labels && (
|
|
1065
|
+
{labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (
|
|
429
1066
|
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
|
430
|
-
{
|
|
1067
|
+
{currentLabelsMessage
|
|
431
1068
|
.labelUids!.map((uid) => labels.find((l) => l.uid === uid))
|
|
432
1069
|
.filter((l): l is Label => !!l)
|
|
433
1070
|
.map((l) => (
|
|
@@ -477,7 +1114,7 @@ export default function MessageDetailPane({
|
|
|
477
1114
|
className="flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm"
|
|
478
1115
|
>
|
|
479
1116
|
<span>
|
|
480
|
-
{
|
|
1117
|
+
{senderLabel} requested a {type} receipt for this
|
|
481
1118
|
message.
|
|
482
1119
|
</span>
|
|
483
1120
|
<Button
|
|
@@ -505,34 +1142,75 @@ export default function MessageDetailPane({
|
|
|
505
1142
|
<Alert>{receiptError}</Alert>
|
|
506
1143
|
</div>
|
|
507
1144
|
)}
|
|
508
|
-
{
|
|
509
|
-
<
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
href={attachmentContentUrl(attachment.uid)}
|
|
514
|
-
className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
|
|
515
|
-
>
|
|
516
|
-
{attachment.filename} ({formatBytes(attachment.sizeBytes)})
|
|
517
|
-
</a>
|
|
518
|
-
</li>
|
|
519
|
-
))}
|
|
520
|
-
</ul>
|
|
1145
|
+
{security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (
|
|
1146
|
+
<p role="status" className="mt-3 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
|
|
1147
|
+
These attachments come from a message whose signature couldn’t be verified. Open them only if
|
|
1148
|
+
you trust the sender.
|
|
1149
|
+
</p>
|
|
521
1150
|
)}
|
|
1151
|
+
{innerAttachments
|
|
1152
|
+
? innerAttachments.length > 0 && (
|
|
1153
|
+
<ul className="flex flex-wrap gap-2 mt-3">
|
|
1154
|
+
{innerAttachments.map((attachment, index) => (
|
|
1155
|
+
<li key={`${index}:${attachment.filename ?? ""}`}>
|
|
1156
|
+
<button
|
|
1157
|
+
type="button"
|
|
1158
|
+
onClick={() => downloadMimeAttachment(attachment)}
|
|
1159
|
+
className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
|
|
1160
|
+
>
|
|
1161
|
+
{attachment.filename ?? `Unnamed ${attachment.contentType} attachment`}
|
|
1162
|
+
</button>
|
|
1163
|
+
</li>
|
|
1164
|
+
))}
|
|
1165
|
+
</ul>
|
|
1166
|
+
)
|
|
1167
|
+
: attachments.length > 0 && (
|
|
1168
|
+
<ul className="flex flex-wrap gap-2 mt-3">
|
|
1169
|
+
{attachments.map((attachment) => (
|
|
1170
|
+
<li key={attachment.uid}>
|
|
1171
|
+
<a
|
|
1172
|
+
href={attachmentContentUrl(attachment.uid)}
|
|
1173
|
+
className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
|
|
1174
|
+
>
|
|
1175
|
+
{attachment.filename} ({formatBytes(attachment.sizeBytes)})
|
|
1176
|
+
</a>
|
|
1177
|
+
</li>
|
|
1178
|
+
))}
|
|
1179
|
+
</ul>
|
|
1180
|
+
)}
|
|
522
1181
|
</div>
|
|
523
1182
|
{security?.decryptError && (
|
|
524
1183
|
<div className="px-4 pt-2">
|
|
525
1184
|
<Alert>{security.decryptError}</Alert>
|
|
1185
|
+
{!getUnlockedKeys(message.mailboxUid) && (
|
|
1186
|
+
<button
|
|
1187
|
+
type="button"
|
|
1188
|
+
onClick={handleUnlockToView}
|
|
1189
|
+
className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
1190
|
+
>
|
|
1191
|
+
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
1192
|
+
Unlock to view this message
|
|
1193
|
+
</button>
|
|
1194
|
+
)}
|
|
526
1195
|
</div>
|
|
527
1196
|
)}
|
|
528
|
-
{security?.
|
|
1197
|
+
{security?.text !== undefined ? (
|
|
1198
|
+
// A recovered text/plain body renders as text (React escapes it) - never as markup.
|
|
1199
|
+
<pre
|
|
1200
|
+
aria-label={message.subject || "Message content"}
|
|
1201
|
+
className="flex-1 w-full overflow-auto p-4 m-0 text-sm font-sans whitespace-pre-wrap break-words"
|
|
1202
|
+
>
|
|
1203
|
+
{security.text}
|
|
1204
|
+
</pre>
|
|
1205
|
+
) : security?.html !== undefined ? (
|
|
529
1206
|
// A decrypted/verified body never came through the server's own sanitize-html pass (it
|
|
530
|
-
// couldn't - the server never saw the plaintext) -
|
|
531
|
-
//
|
|
1207
|
+
// couldn't - the server never saw the plaintext) - it's sanitized here, client-side, before
|
|
1208
|
+
// it ever touches the DOM (remote images/stylesheets/`url()`s stripped too, so opening it
|
|
1209
|
+
// can't ping a tracker), behind a no-remote-loads CSP, on top of the iframe's `sandbox=""`.
|
|
532
1210
|
<iframe
|
|
533
1211
|
key={message.uid}
|
|
534
1212
|
title={message.subject || "Message content"}
|
|
535
|
-
srcDoc={
|
|
1213
|
+
srcDoc={buildSecureSrcDoc(security.html)}
|
|
536
1214
|
sandbox=""
|
|
537
1215
|
className="flex-1 w-full border-0"
|
|
538
1216
|
/>
|
|
@@ -568,18 +1246,63 @@ export default function MessageDetailPane({
|
|
|
568
1246
|
</Button>
|
|
569
1247
|
</div>
|
|
570
1248
|
</Modal>
|
|
1249
|
+
<Modal
|
|
1250
|
+
open={trustConfirmOpen && !!trustableCertificate}
|
|
1251
|
+
onClose={() => !trusting && setTrustConfirmOpen(false)}
|
|
1252
|
+
title="Trust this signer?"
|
|
1253
|
+
>
|
|
1254
|
+
<div className="flex flex-col gap-3 text-sm">
|
|
1255
|
+
<p>
|
|
1256
|
+
Mail from <span className="font-medium">{senderAddress}</span> signed with this certificate will show as
|
|
1257
|
+
verified.
|
|
1258
|
+
</p>
|
|
1259
|
+
<dl className="flex flex-col gap-1">
|
|
1260
|
+
<dt className="text-xs text-text-muted">Certificate issued to</dt>
|
|
1261
|
+
<dd>{security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address"}</dd>
|
|
1262
|
+
<dt className="text-xs text-text-muted">Fingerprint</dt>
|
|
1263
|
+
<dd className="font-mono text-xs break-all">
|
|
1264
|
+
{security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown"}
|
|
1265
|
+
</dd>
|
|
1266
|
+
</dl>
|
|
1267
|
+
<p className="text-text-muted">
|
|
1268
|
+
Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with
|
|
1269
|
+
the sender through another channel, such as a phone call.
|
|
1270
|
+
</p>
|
|
1271
|
+
{trustError && <Alert>{trustError}</Alert>}
|
|
1272
|
+
<div className="flex gap-3">
|
|
1273
|
+
<Button
|
|
1274
|
+
type="button"
|
|
1275
|
+
className="!w-auto"
|
|
1276
|
+
loading={trusting}
|
|
1277
|
+
disabled={trusting}
|
|
1278
|
+
onClick={() => handleTrustSigner(senderAddress, trustableCertificate!)}
|
|
1279
|
+
>
|
|
1280
|
+
Trust
|
|
1281
|
+
</Button>
|
|
1282
|
+
<Button
|
|
1283
|
+
type="button"
|
|
1284
|
+
variant="secondary"
|
|
1285
|
+
className="!w-auto"
|
|
1286
|
+
disabled={trusting}
|
|
1287
|
+
onClick={() => setTrustConfirmOpen(false)}
|
|
1288
|
+
>
|
|
1289
|
+
Cancel
|
|
1290
|
+
</Button>
|
|
1291
|
+
</div>
|
|
1292
|
+
</div>
|
|
1293
|
+
</Modal>
|
|
571
1294
|
<Modal open={labelsOpen} onClose={() => setLabelsOpen(false)} title="Labels">
|
|
572
1295
|
{labelsError && <Alert>{labelsError}</Alert>}
|
|
573
1296
|
<ul className="flex flex-col gap-1">
|
|
574
1297
|
{(labels ?? []).map((l) => {
|
|
575
|
-
const checked =
|
|
1298
|
+
const checked = currentLabelsMessage.labelUids?.includes(l.uid) ?? false;
|
|
576
1299
|
return (
|
|
577
1300
|
<li key={l.uid}>
|
|
578
1301
|
<label className="flex items-center gap-2 py-1.5 px-1 text-sm rounded-sm hover:bg-surface-alt">
|
|
579
1302
|
<input
|
|
580
1303
|
type="checkbox"
|
|
581
1304
|
checked={checked}
|
|
582
|
-
disabled={togglingLabelUid
|
|
1305
|
+
disabled={togglingLabelUid !== null}
|
|
583
1306
|
onChange={() => handleToggleLabel(l.uid)}
|
|
584
1307
|
/>
|
|
585
1308
|
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
|