@rapidmx/web-client 0.4.0 → 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.
Files changed (169) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/data-requests/index.tsx +480 -485
  3. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  4. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  5. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  6. package/apps/admin/index.tsx +19 -1
  7. package/apps/admin/ingest-queue/index.tsx +44 -5
  8. package/apps/admin/mailboxes/[uid].tsx +211 -184
  9. package/apps/admin/quarantine/index.tsx +46 -14
  10. package/apps/admin/transport-rules/[uid].tsx +30 -1
  11. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  12. package/apps/admin/transport-rules/index.tsx +24 -6
  13. package/apps/admin/transport-rules/new/index.tsx +30 -1
  14. package/apps/escrow/_layout.tsx +2 -3
  15. package/apps/escrow/audit-log/index.tsx +196 -168
  16. package/apps/escrow/matters/[uid].tsx +621 -521
  17. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  18. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
  19. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  20. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  21. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
  23. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
  25. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
  26. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
  27. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
  28. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
  31. package/apps/shared/components/admin/signOut.ts +79 -0
  32. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  33. package/apps/shared/components/calendar/EventModal.tsx +717 -549
  34. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  35. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  36. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  37. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  38. package/apps/shared/components/calendar/allDay.ts +124 -0
  39. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  40. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  41. package/apps/shared/components/contacts/ContactForm.tsx +377 -348
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  43. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  44. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  46. package/apps/shared/components/layout/AppShell.tsx +87 -15
  47. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  50. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  51. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
  52. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  53. package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
  54. package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
  55. package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  57. package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
  58. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  59. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  60. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  61. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  62. package/apps/shared/mail/listAllPages.ts +39 -0
  63. package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
  64. package/apps/shared/search/localIndexBuilder.ts +481 -179
  65. package/apps/shared/search/localIndexRpcClient.ts +228 -28
  66. package/apps/shared/search/localIndexSchema.ts +15 -6
  67. package/apps/shared/search/localIndexStorage.ts +98 -0
  68. package/apps/shared/search/localIndexVFS.ts +32 -1
  69. package/apps/shared/search/localIndexWorker.ts +648 -186
  70. package/apps/www/calendar/index.tsx +438 -417
  71. package/apps/www/contacts/[uid].tsx +21 -2
  72. package/apps/www/contacts/index.tsx +116 -12
  73. package/apps/www/index.tsx +1314 -1041
  74. package/apps/www/messages/[uid].tsx +18 -6
  75. package/apps/www/settings/auto-reply/index.tsx +134 -129
  76. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  77. package/apps/www/settings/encryption/index.tsx +739 -216
  78. package/apps/www/settings/filters/[uid].tsx +171 -155
  79. package/apps/www/settings/filters/new/index.tsx +8 -1
  80. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  81. package/apps/www/settings/privacy/index.tsx +494 -436
  82. package/apps/www/settings/read-receipts/index.tsx +148 -125
  83. package/apps/www/settings/sharing/index.tsx +17 -14
  84. package/apps/www/tasks/index.tsx +638 -579
  85. package/dist/apps/admin/audit-log/index.js +38 -8
  86. package/dist/apps/admin/data-requests/index.js +52 -72
  87. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  88. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  89. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  90. package/dist/apps/admin/index.js +4 -1
  91. package/dist/apps/admin/ingest-queue/index.js +24 -6
  92. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  93. package/dist/apps/admin/quarantine/index.js +20 -11
  94. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  95. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  96. package/dist/apps/admin/transport-rules/index.js +22 -6
  97. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  98. package/dist/apps/escrow/_layout.js +3 -3
  99. package/dist/apps/escrow/audit-log/index.js +26 -1
  100. package/dist/apps/escrow/matters/[uid].js +77 -42
  101. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  102. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
  103. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  104. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  105. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  106. package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
  107. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
  108. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
  109. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
  110. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
  111. package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
  112. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
  113. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
  114. package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
  115. package/dist/apps/shared/components/admin/signOut.js +75 -0
  116. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  117. package/dist/apps/shared/components/calendar/EventModal.js +142 -21
  118. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  119. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  120. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  121. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  122. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  123. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  124. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  125. package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
  126. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  127. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  128. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  129. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  130. package/dist/apps/shared/components/layout/AppShell.js +83 -14
  131. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  132. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
  133. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  134. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  135. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
  136. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  137. package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
  138. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  139. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
  140. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  141. package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
  142. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  143. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  144. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  145. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  146. package/dist/apps/shared/mail/listAllPages.js +25 -0
  147. package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
  148. package/dist/apps/shared/search/localIndexBuilder.js +292 -37
  149. package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
  150. package/dist/apps/shared/search/localIndexSchema.js +12 -6
  151. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  152. package/dist/apps/shared/search/localIndexVFS.js +68 -40
  153. package/dist/apps/shared/search/localIndexWorker.js +519 -173
  154. package/dist/apps/www/calendar/index.js +28 -10
  155. package/dist/apps/www/contacts/[uid].js +13 -2
  156. package/dist/apps/www/contacts/index.js +80 -10
  157. package/dist/apps/www/index.js +339 -108
  158. package/dist/apps/www/messages/[uid].js +18 -6
  159. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  160. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  161. package/dist/apps/www/settings/encryption/index.js +514 -97
  162. package/dist/apps/www/settings/filters/[uid].js +22 -7
  163. package/dist/apps/www/settings/filters/new/index.js +8 -2
  164. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  165. package/dist/apps/www/settings/privacy/index.js +57 -22
  166. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  167. package/dist/apps/www/settings/sharing/index.js +7 -11
  168. package/dist/apps/www/tasks/index.js +50 -10
  169. package/package.json +2 -2
@@ -1,20 +1,28 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useEffect, useState } from "react";
7
- import { HiOutlineLockClosed } from "react-icons/hi2";
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { HiOutlineCheck, HiOutlineExclamationTriangle, HiOutlineLockClosed } from "react-icons/hi2";
8
8
  import DOMPurify from "dompurify";
9
9
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
10
- import { approveReceipt, archiveMessage, attachmentContentUrl, cancelScheduledSend, classifyMessage, declineReceipt, getMessageRawContent, recallMessage, setMessageLabels, } from "@rapidmx/react-shared/mail/mailApi.js";
10
+ import { approveReceipt, archiveMessage, attachmentContentUrl, cancelScheduledSend, classifyMessage, declineReceipt, getMessage, getMessageRawContent, recallMessage, setMessageLabels, } from "@rapidmx/react-shared/mail/mailApi.js";
11
11
  import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
12
- import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
13
- import { evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
12
+ import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
13
+ import { evaluateMessageSecurity, evaluateMessageSecurityWithSeal, } from "@rapidmx/react-shared/crypto/messageSecurity.js";
14
+ import { extractAddresses } from "@rapidmx/react-shared/crypto/mime.js";
15
+ import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
14
16
  import { isLikelyMailingList } from "@rapidmx/react-shared/crypto/composeSecurity.js";
17
+ import { clearPinnedSignerCache, getPinnedSignerFingerprints, getSignerKeyState } from "./pinnedSigners.js";
18
+ import { currentVerificationSeal, getVaultGeneration, sendVerificationSeal } from "./verificationSeals.js";
19
+ import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
20
+ import KeyChangeReview from "../contacts/KeyChangeReview.js";
21
+ import { KEY_CHANGE_STALE_MESSAGE, sameFingerprint } from "../contacts/contactKeys.js";
15
22
  import { useCompose } from "./compose/ComposeContext.js";
16
23
  import { useMailShell } from "./layout/MailShell.js";
17
24
  import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
25
+ import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
18
26
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
19
27
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
20
28
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
@@ -28,11 +36,157 @@ const SECURITY_INDICATOR = {
28
36
  encrypted: { label: "Encrypted", className: "bg-primary/10 text-primary-dark" },
29
37
  signed_verified: { label: "Signed & verified", className: "bg-success/10 text-success" },
30
38
  encrypted_verified: { label: "Encrypted & verified", className: "bg-success/10 text-success" },
39
+ // A valid signature from a certificate the reader hasn't pinned - anyone can mint a certificate naming the
40
+ // sender, so this must never look like the verified states.
41
+ signed_unverified_signer: { label: "Signed - signer not verified", className: "bg-warning/15 text-text" },
42
+ encrypted_unverified_signer: { label: "Encrypted - signer not verified", className: "bg-warning/15 text-text" },
31
43
  signature_failed: { label: "Signature failed", className: "bg-danger-bg text-danger" },
44
+ // Verified when first opened (a verification seal), but not live: muted, never the green verified pill. A signer key
45
+ // later reported compromised switches it to `LATER_COMPROMISED_INDICATOR_CLASS`.
46
+ verified_at_first_open: { label: "Verified when first opened", className: "bg-surface-alt text-text-muted" },
32
47
  };
33
- function SecurityIndicator({ state }) {
34
- const { label, className } = SECURITY_INDICATOR[state];
35
- return _jsx("span", { className: `text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`, children: label });
48
+ /** The amber `verified_at_first_open` badge for a signer key since reported compromised. */
49
+ const LATER_COMPROMISED_INDICATOR_CLASS = "bg-warning/15 text-text";
50
+ /** Whether a result is (or, for `verified_at_first_open`, was live) a `signer_key_changed` failure. */
51
+ function signerKeyChanged(result) {
52
+ return result.signatureFailureReason === "signer_key_changed" || result.liveSignatureFailureReason === "signer_key_changed";
53
+ }
54
+ /** The detail line under a `verified_at_first_open` badge. */
55
+ export function verifiedAtFirstOpenMessage(result) {
56
+ const date = new Date(result.verifiedAt).toLocaleDateString();
57
+ if (result.laterCompromised) {
58
+ return `This signature was verified on ${date}, but the sender's key was later reported compromised; treat this message with caution.`;
59
+ }
60
+ if (result.liveSignatureFailureReason === "signer_key_changed") {
61
+ return `This signature was verified on ${date}. The sender has since started signing with a different key.`;
62
+ }
63
+ 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.`;
64
+ }
65
+ const VERIFIED_STATES = new Set(["signed_verified", "encrypted_verified"]);
66
+ const UNVERIFIED_SIGNER_STATES = new Set(["signed_unverified_signer", "encrypted_unverified_signer"]);
67
+ /** An `@`, or a look-alike a reader would take for one (fullwidth, small) - restapi's own `AT_SIGN_LIKE`. */
68
+ const AT_SIGN_LIKE = /[@\uFF20\uFE6B]/;
69
+ /** Whether a sender's display name poses as an address, and whether that address differs from the one actually sent
70
+ * from. Compatibility forms are folded first (NFKC turns the fullwidth and small @ into `@`), so a look-alike can't
71
+ * hide the token. */
72
+ export function checkSenderName(displayName, actualAddress) {
73
+ if (!displayName || !AT_SIGN_LIKE.test(displayName)) {
74
+ return { looksLikeAddress: false, misleading: false };
75
+ }
76
+ const shown = /[^\s<>"'(),;:]+@[^\s<>"'(),;:]+/.exec(displayName.normalize("NFKC"))?.[0];
77
+ return { looksLikeAddress: true, misleading: shown !== undefined && shown.toLowerCase() !== actualAddress.toLowerCase() };
78
+ }
79
+ export const TRUST_SIGNER_CONFLICT_MESSAGE = "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.";
80
+ export const TRUST_SIGNER_INVALID_MESSAGE = "This certificate can't be trusted for this sender.";
81
+ export const TRUST_SIGNER_FORBIDDEN_MESSAGE = "You don't have permission to trust signers for this mailbox.";
82
+ export const TRUST_SIGNER_GENERIC_MESSAGE = "Couldn't trust this signer. Try again.";
83
+ export const KEPT_CURRENT_SIGNING_KEY_MESSAGE = "You kept the current signing key for this sender. This message stays unverified.";
84
+ /** The error text for a failed `trustSigner()`. */
85
+ export function trustSignerErrorMessage(err) {
86
+ if (err instanceof SignerKeyConflictError) {
87
+ return TRUST_SIGNER_CONFLICT_MESSAGE;
88
+ }
89
+ if (err instanceof ApiRequestError && err.status === 400) {
90
+ return TRUST_SIGNER_INVALID_MESSAGE;
91
+ }
92
+ if (err instanceof ApiRequestError && err.status === 403) {
93
+ return TRUST_SIGNER_FORBIDDEN_MESSAGE;
94
+ }
95
+ return TRUST_SIGNER_GENERIC_MESSAGE;
96
+ }
97
+ /** Groups a fingerprint into 4-character blocks (`AB12 CD34 ...`), ignoring any separators it came with, so it can be
98
+ * read out and compared with the sender over another channel. */
99
+ export function formatFingerprint(fingerprint) {
100
+ const compact = fingerprint.replace(/[\s:]/g, "").toUpperCase();
101
+ return compact.match(/.{1,4}/g)?.join(" ") ?? compact;
102
+ }
103
+ /** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
104
+ * opaque download (`application/octet-stream`), never rendered in this origin. */
105
+ function downloadMimeAttachment(attachment) {
106
+ // `decode()` is `undefined` only for invalid base64 - an empty file is the honest result then.
107
+ const bytes = attachment.decode() ?? new Uint8Array(0);
108
+ const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
109
+ const a = document.createElement("a");
110
+ a.href = url;
111
+ a.download = attachment.filename ?? "attachment";
112
+ document.body.appendChild(a);
113
+ a.click();
114
+ a.remove();
115
+ // Some browsers are still reading the blob after `click()` returns.
116
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
117
+ }
118
+ function SecurityIndicator({ security }) {
119
+ const { label, className } = SECURITY_INDICATOR[security.state];
120
+ if (security.state !== "verified_at_first_open") {
121
+ return _jsx("span", { className: `text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`, children: label });
122
+ }
123
+ 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] }));
124
+ }
125
+ /** User-facing explanation for each `signatureFailureReason` - shown alongside the "Signature failed"
126
+ * badge so an unverifiable (e.g. foreign/unparseable) signed message reads as "not verified, and here's
127
+ * why" rather than as a bare error. The body itself stays visible either way. */
128
+ const SIGNATURE_FAILURE_MESSAGE = {
129
+ invalid_signature: "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.",
130
+ untrusted_signer: "This message was signed with a certificate that doesn't match the sender's known key. Treat it as unverified.",
131
+ // Never shown: the dedicated "signing key changed" notice replaces it. Present so every reason has copy.
132
+ signer_key_changed: "This message was signed with a different key than the one you trust for this sender. Treat it as unverified.",
133
+ signer_identity_mismatch: "This message's signing certificate doesn't belong to the sender shown in From. Treat it as unverified.",
134
+ header_mismatch: "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.",
135
+ };
136
+ const GENERIC_SIGNATURE_FAILURE_MESSAGE = "This message's digital signature couldn't be verified. Treat it as unverified.";
137
+ /** Fallback when the raw MIME for an encrypted message can't even be fetched/evaluated - an encrypted
138
+ * message must never be mislabeled "Unprotected" just because loading failed. */
139
+ const ENCRYPTED_LOAD_ERROR = "Couldn't load this message's encrypted content.";
140
+ /** Content Security Policy prepended to every client-rendered (decrypted/verified) body's `srcDoc` - the
141
+ * backstop behind the sanitizer below: nothing in the document may load any remote resource at all, only
142
+ * inline styles and `data:`/`cid:` images. */
143
+ const BODY_CSP_META = "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; img-src data: cid:; style-src 'unsafe-inline'\">";
144
+ const EMBEDDED_URI = /^\s*(?:data|cid):/i;
145
+ /** Attributes whose value makes the browser fetch a resource (as opposed to a user-clicked link). */
146
+ const RESOURCE_URI_ATTRIBUTES = new Set(["src", "srcset", "background", "poster", "lowsrc", "dynsrc", "xlink:href", "action", "formaction"]);
147
+ /** Decodes CSS escapes first (so an escaped `u\72l(` can't hide from the checks below), then drops every
148
+ * `@import` and neutralizes every `url()`/`image-set()` reference that isn't a `data:`/`cid:` URI. */
149
+ export function stripRemoteCssUrls(css) {
150
+ return css
151
+ .replace(/\\([0-9a-f]{1,6})\s?/gi, (_match, hex) => String.fromCodePoint(Math.min(parseInt(hex, 16), 0x10ffff)))
152
+ .replace(/\\(.)/g, "$1")
153
+ .replace(/@import[^;]*;?/gi, "")
154
+ .replace(/(?:-webkit-)?image-set\((?:[^()]|\([^()]*\))*\)/gi, (match) => [...match.matchAll(/(["'])(.*?)\1/g)].every(([, , uri]) => EMBEDDED_URI.test(uri)) ? match : "none")
155
+ .replace(/url\(\s*(["']?)(.*?)\1\s*\)/gi, (match, _quote, uri) => (EMBEDDED_URI.test(uri) ? match : "none"));
156
+ }
157
+ let bodyPurifier;
158
+ /** A dedicated DOMPurify instance (hooks registered here never leak into any other DOMPurify caller)
159
+ * that additionally strips every remote resource reference - see `stripRemoteCssUrls()`. */
160
+ function getBodyPurifier() {
161
+ if (!bodyPurifier) {
162
+ bodyPurifier = DOMPurify(window);
163
+ bodyPurifier.addHook("uponSanitizeElement", (node, data) => {
164
+ if (data.tagName === "style") {
165
+ // An element's `textContent` is always a string (only documents/doctypes yield null).
166
+ node.textContent = stripRemoteCssUrls(node.textContent);
167
+ }
168
+ });
169
+ bodyPurifier.addHook("uponSanitizeAttribute", (node, data) => {
170
+ const name = data.attrName.toLowerCase();
171
+ if (name === "style") {
172
+ data.attrValue = stripRemoteCssUrls(data.attrValue);
173
+ return;
174
+ }
175
+ const isNavigationLink = name === "href" && ["a", "area"].includes(node.nodeName.toLowerCase());
176
+ if (RESOURCE_URI_ATTRIBUTES.has(name) || (name === "href" && !isNavigationLink)) {
177
+ const candidates = name === "srcset" ? data.attrValue.split(/,\s+/) : [data.attrValue];
178
+ if (!candidates.every((candidate) => EMBEDDED_URI.test(candidate))) {
179
+ data.keepAttr = false;
180
+ }
181
+ }
182
+ });
183
+ }
184
+ return bodyPurifier;
185
+ }
186
+ /** Sanitizes a client-rendered body for `srcDoc`, with the CSP meta as its very first element. */
187
+ export function buildSecureSrcDoc(html) {
188
+ const sanitized = getBodyPurifier().sanitize(html, { FORBID_TAGS: ["link", "meta", "base"] });
189
+ return BODY_CSP_META + sanitized;
36
190
  }
37
191
  function formatBytes(bytes) {
38
192
  if (bytes >= 1000000)
@@ -48,7 +202,30 @@ function formatBytes(bytes) {
48
202
  * a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
49
203
  * site for how `message`/`attachments`/`isSentItems` are sourced.
50
204
  */
51
- export default function MessageDetailPane({ message, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, isInbox, onClassified, onReceiptHandled, onArchived, labels, onLabelsChanged, }) {
205
+ export default function MessageDetailPane(props) {
206
+ if (!props.message) {
207
+ return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a message to read it." });
208
+ }
209
+ // Keyed by uid so every piece of per-message state below (security result, open modals, in-flight
210
+ // flags, errors) resets cleanly when a caller swaps `message` in place rather than remounting.
211
+ return _jsx(MessageDetailContent, { ...props, message: props.message }, props.message.uid);
212
+ }
213
+ /** Whether a message's raw MIME has to be fetched to evaluate its security state. `encrypted` is the
214
+ * server's own classification; there is no server-side "signed" flag, but every S/MIME-signed shape
215
+ * (detached `multipart/signed`'s `smime.p7s`, opaque-signed `smime.p7m`) is stored with its CMS part
216
+ * surfaced as an attachment - so a message with neither flag can't carry a signature to verify, and
217
+ * fetching its full raw source (attachments included) would be pure waste. */
218
+ function needsRawSecurityEvaluation(message) {
219
+ return !!message.encrypted || message.hasAttachments;
220
+ }
221
+ function MessageDetailContent({ message: messageProp, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, isInbox, onClassified, onReceiptHandled, onArchived, labels, onLabelsChanged, }) {
222
+ // A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
223
+ // `reloadMessage()`. A prop copy newer by `version` wins; an equal-version reload is kept, since claiming a send
224
+ // lease need not bump `version`.
225
+ const [reloaded, setReloaded] = useState(null);
226
+ const message = reloaded && reloaded.version >= messageProp.version ? reloaded : messageProp;
227
+ // A reload that finds the message already moved on (sent, or moved to Drafts elsewhere) leaves Outbox.
228
+ const inOutbox = !!isOutbox && message.folderUid === messageProp.folderUid;
52
229
  const { openCompose } = useCompose();
53
230
  const [confirming, setConfirming] = useState(false);
54
231
  const [recalling, setRecalling] = useState(false);
@@ -71,46 +248,180 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
71
248
  const [receiptBusy, setReceiptBusy] = useState(null);
72
249
  const [receiptError, setReceiptError] = useState(null);
73
250
  const [security, setSecurity] = useState(null);
251
+ // Whether the pinned-signer lookup for the sender *succeeded* and found no signing keys - the only case "Trust this
252
+ // signer" is offered in. A failed lookup could be hiding an existing pin, and trusting never replaces one.
253
+ const [senderUnpinned, setSenderUnpinned] = useState(false);
254
+ const [trustConfirmOpen, setTrustConfirmOpen] = useState(false);
255
+ const [trusting, setTrusting] = useState(false);
256
+ const [trustError, setTrustError] = useState(null);
257
+ // The sender's stored signing-key state, loaded for a `signer_key_changed` result (the comparison) and for an
258
+ // unverified signer with no pins (a recorded conflict sends the reader to the contact instead of offering trust).
259
+ // `undefined` when not needed or it couldn't be loaded.
260
+ const [senderKeyState, setSenderKeyState] = useState(undefined);
261
+ // Whether the reader may update this mailbox (resolving a key change needs it) - `undefined` when unknown.
262
+ const [canUpdateMailbox, setCanUpdateMailbox] = useState(undefined);
263
+ // "Keys changed while you were looking" after a 409, or the outcome of keeping the current key. Survives the
264
+ // re-evaluation that follows.
265
+ const [keyChangeNotice, setKeyChangeNotice] = useState(null);
266
+ // "Now", for deciding whether a scheduled send's lease is still live - advanced when the lease runs out.
267
+ const [nowMs, setNowMs] = useState(() => Date.now());
74
268
  const { mailboxes } = useMailShell();
75
269
  const { requestUnlock } = useUnlockPrompt();
76
270
  // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
77
271
  // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
78
272
  // state; see keySession.ts's own doc comment).
79
273
  const [unlockRefresh, setUnlockRefresh] = useState(0);
80
- // Evaluates every message's security state, not just ones flagged `encrypted` - a detached
81
- // `multipart/signed` message needs its raw MIME read too (a sanitized HTML body never carries the
82
- // signature part) to tell "Signed & verified" apart from plain "Unprotected". A fetch/parse failure
83
- // degrades to "Unprotected" rather than surfacing an error - the spec treats that as this module's
84
- // safe default, not a distinct failure state of its own.
274
+ // The newest copy of this message known to the Labels popover - normally just the `message` prop,
275
+ // but a successful label toggle's server response is held here too, so a follow-up toggle always
276
+ // builds on the latest `labelUids`/`version` even before (or without) the caller patching its own
277
+ // state via `onLabelsChanged`. A prop copy at least as new (by `version`) always wins.
278
+ const [labelsMessage, setLabelsMessage] = useState(message);
279
+ const currentLabelsMessage = message.version >= labelsMessage.version ? message : labelsMessage;
280
+ const latestLabelsMessageRef = useRef(message);
281
+ const rawEvaluationNeeded = needsRawSecurityEvaluation(message);
282
+ // Every address the viewing mailbox receives at (primary first), as one string so the effect below only
283
+ // re-runs when they actually change.
284
+ const readerMailbox = mailboxes.find((mb) => mb.uid === message.mailboxUid);
285
+ const readerAddressesKey = readerMailbox ? [readerMailbox.primarySmtpAddress, ...(readerMailbox.aliasAddresses ?? [])].join(" ") : "";
286
+ // Evaluates a message's security state from its raw MIME - only for a message that can actually be
287
+ // encrypted or signed (see `needsRawSecurityEvaluation()`); anything else is "Unprotected" without a
288
+ // fetch. A detached `multipart/signed` message needs its raw MIME read (a sanitized HTML body never
289
+ // carries the signature part) to tell "Signed & verified" apart from plain "Unprotected". A
290
+ // fetch/parse failure degrades to "Unprotected" (or, for a server-flagged encrypted message, to
291
+ // "Encrypted" with an explanation) rather than hiding the message.
292
+ //
293
+ // `getMessageRawContent()` takes no `AbortSignal`, so a superseded fetch can't be aborted - its
294
+ // result is ignored via `cancelled` instead (and never handed to `evaluateMessageSecurity()`).
85
295
  useEffect(() => {
86
- if (!message) {
87
- setSecurity(null);
296
+ if (!rawEvaluationNeeded) {
297
+ setSecurity({ state: "unprotected" });
88
298
  return;
89
299
  }
90
300
  let cancelled = false;
91
301
  setSecurity(null);
302
+ setSenderUnpinned(false);
303
+ setSenderKeyState(undefined);
304
+ // The sender's trusted signing keys: what key discovery pinned on this mailbox's contacts, plus this
305
+ // mailbox's own signing keys when it sent the message itself. A failed lookup means no pins, which can only
306
+ // ever make a signature "signer not verified", never verified.
307
+ const senderAddress = message.from.address;
308
+ const pinsPromise = getPinnedSignerFingerprints(message.mailboxUid, senderAddress).then((fingerprints) => ({ loaded: true, fingerprints }), () => ({ loaded: false, fingerprints: [] }));
309
+ // Verification seals (see `verificationSeals.ts`) need unlocked keys and the vault's current master key
310
+ // generation; without either the message is evaluated exactly as before, with no seal. The sender's key records
311
+ // are loaded alongside, so a seal can say when the signer key was later revoked as compromised.
312
+ let keyStatePromise;
313
+ const loadKeyState = () => (keyStatePromise ?? (keyStatePromise = getSignerKeyState(message.mailboxUid, senderAddress).catch(() => undefined)));
314
+ const sealContextPromise = getUnlockedKeys(message.mailboxUid)
315
+ ? getVaultGeneration(message.mailboxUid).then(async (generation) => generation === undefined ? undefined : { generation, keyState: await loadKeyState() })
316
+ : undefined;
92
317
  getMessageRawContent(message.uid)
93
- .then((rawMime) => evaluateMessageSecurity(rawMime, getUnlockedKeys(message.mailboxUid)))
94
- .then((result) => {
318
+ .then(async (rawMime) => {
319
+ if (cancelled) {
320
+ return;
321
+ }
322
+ const [primaryAddress, ...aliasAddresses] = readerAddressesKey ? readerAddressesKey.split(" ") : [];
323
+ const ownAddresses = readerAddressesKey.toLowerCase().split(" ");
324
+ const ownKeys = ownAddresses.includes(senderAddress.toLowerCase()) ? (readerMailbox.keys ?? []) : [];
325
+ const ownPins = signingKeyFingerprints(ownKeys);
326
+ const [contactPins, sealContext] = await Promise.all([pinsPromise, sealContextPromise]);
327
+ const pins = [...new Set([...contactPins.fingerprints, ...ownPins])];
328
+ const pinned = pins.length > 0 ? pins : undefined;
329
+ // The raw content is a byte string - only ever handed to evaluateMessageSecurity(), never shown. Keys
330
+ // are read after the pin lookup's await, so a lock that happened meanwhile is honored.
331
+ const unlocked = getUnlockedKeys(message.mailboxUid);
332
+ let result;
333
+ if (sealContext && unlocked) {
334
+ const signerKeys = [
335
+ ...(sealContext.keyState?.pinned ?? []),
336
+ ...(sealContext.keyState?.previous ?? []),
337
+ ...ownKeys,
338
+ ];
339
+ result = await evaluateMessageSecurityWithSeal(rawMime, unlocked, pinned, primaryAddress, {
340
+ mailboxUid: message.mailboxUid,
341
+ messageUid: message.uid,
342
+ ...currentVerificationSeal(message),
343
+ masterKeyGeneration: sealContext.generation,
344
+ signerKeys,
345
+ }).catch(() =>
346
+ // Only a lock while sealing throws: evaluate as locked (the lock itself re-evaluates too).
347
+ evaluateMessageSecurity(rawMime, undefined, pinned, primaryAddress));
348
+ if (result.sealToWrite) {
349
+ // Best effort and in the background: never awaited, never shown.
350
+ void sendVerificationSeal(message.uid, result.sealToWrite);
351
+ }
352
+ }
353
+ else {
354
+ result = await evaluateMessageSecurity(rawMime, unlocked, pinned, primaryAddress);
355
+ }
356
+ // Only one reader address can be checked per evaluation: a message sent to one of this mailbox's
357
+ // aliases isn't "not addressed to you", so each alias is tried before saying so.
358
+ for (const alias of aliasAddresses) {
359
+ if (!result.notAddressedToReader) {
360
+ break;
361
+ }
362
+ const viaAlias = await evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
363
+ result = { ...result, notAddressedToReader: viaAlias.notAddressedToReader };
364
+ }
365
+ const unpinned = contactPins.loaded && pins.length === 0;
366
+ const keyChanged = signerKeyChanged(result);
367
+ const [keyState, access] = await Promise.all([
368
+ keyChanged || (unpinned && UNVERIFIED_SIGNER_STATES.has(result.state)) ? loadKeyState() : undefined,
369
+ keyChanged ? getMyMailboxAccess(message.mailboxUid).then((a) => a.canUpdate, () => undefined) : undefined,
370
+ ]);
95
371
  if (!cancelled) {
96
372
  setSecurity(result);
373
+ setSenderUnpinned(unpinned);
374
+ setSenderKeyState(keyState);
375
+ setCanUpdateMailbox(access);
97
376
  }
98
377
  })
99
378
  .catch(() => {
100
379
  if (!cancelled) {
101
- setSecurity({ state: "unprotected" });
380
+ setSecurity(message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" });
102
381
  }
103
382
  });
104
383
  return () => {
105
384
  cancelled = true;
106
385
  };
107
- }, [message?.uid, unlockRefresh]);
386
+ }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey]);
387
+ // Decrypted plaintext must not outlive the key session that produced it: the moment this mailbox's
388
+ // keys are destroyed (logout, idle timeout, explicit lock), drop the recovered html/text and
389
+ // re-evaluate - an encrypted message then falls back to its "Unlock to view" state.
390
+ useEffect(() => subscribeKeySession((event) => {
391
+ if (event.mailboxUid === message.mailboxUid && event.state === "locked") {
392
+ setSecurity(null);
393
+ setUnlockRefresh((n) => n + 1);
394
+ }
395
+ }), [message.mailboxUid]);
396
+ // While restapi's ScheduledSendJob holds a send lease the message is being relayed: cancelling or moving it would be
397
+ // refused (409/403), so the Outbox controls are replaced by "Sending...". When the lease runs out the message is
398
+ // re-read - it has either been sent (and left Outbox) or been released back to its schedule.
399
+ const leaseExpiresMs = message.scheduledSendLeaseExpiresAt ? Date.parse(message.scheduledSendLeaseExpiresAt) : NaN;
400
+ const sendInProgress = inOutbox && leaseExpiresMs > nowMs;
401
+ useEffect(() => {
402
+ if (!sendInProgress) {
403
+ return;
404
+ }
405
+ const timer = setTimeout(() => {
406
+ setNowMs(Date.now());
407
+ void reloadMessage();
408
+ }, leaseExpiresMs - nowMs + 1);
409
+ return () => clearTimeout(timer);
410
+ }, [sendInProgress, leaseExpiresMs, nowMs]);
411
+ async function reloadMessage() {
412
+ try {
413
+ setReloaded(await getMessage(message.uid));
414
+ setNowMs(Date.now());
415
+ }
416
+ catch {
417
+ // Keep showing what we have - the next refused action or lease expiry tries again.
418
+ }
419
+ }
108
420
  // Offered only when this device genuinely has no unlocked session for this message's mailbox at all
109
421
  // (as opposed to being unlocked but still unable to decrypt - a wrong/since-rotated key, which
110
422
  // re-unlocking the same session can't fix) - see `evaluateMessageSecurity()`'s own doc comment on why
111
423
  // `decryptError` alone can't distinguish those two cases.
112
424
  async function handleUnlockToView() {
113
- // Only reachable via the button below, which never renders while `message` is null.
114
425
  const mailboxUid = message.mailboxUid;
115
426
  const mailboxKeys = mailboxes.find((mb) => mb.uid === mailboxUid)?.keys ?? [];
116
427
  try {
@@ -121,8 +432,31 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
121
432
  // User dismissed the unlock dialog - security state stays exactly as it was.
122
433
  }
123
434
  }
124
- if (!message) {
125
- return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a message to read it." });
435
+ // Only ever invoked from the "Trust this signer" dialog, which only opens for an unverified-signer result carrying
436
+ // `signerCertificate`. The pin goes on the contact in the mailbox the message belongs to; the evaluation is then
437
+ // re-run against a fresh pinned-signer lookup, so the badge turns verified.
438
+ async function handleTrustSigner(address, certificate) {
439
+ setTrusting(true);
440
+ setTrustError(null);
441
+ try {
442
+ await trustSigner(message.mailboxUid, { address, certificate });
443
+ clearPinnedSignerCache();
444
+ setTrustConfirmOpen(false);
445
+ setUnlockRefresh((n) => n + 1);
446
+ }
447
+ catch (err) {
448
+ setTrustError(trustSignerErrorMessage(err));
449
+ }
450
+ finally {
451
+ setTrusting(false);
452
+ }
453
+ }
454
+ // After a key change was accepted or kept (or the pinned key moved meanwhile): the pinned signers are re-read and
455
+ // the message re-evaluated, so an accepted key turns the badge verified.
456
+ function refreshAfterKeyChange(notice) {
457
+ setKeyChangeNotice(notice);
458
+ clearPinnedSignerCache();
459
+ setUnlockRefresh((n) => n + 1);
126
460
  }
127
461
  // Only ever invoked from the Reply/Reply All/Forward buttons below, which themselves only render
128
462
  // once `message` is loaded (the early return above covers the only other state) — the non-null
@@ -176,19 +510,27 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
176
510
  setRecalling(false);
177
511
  }
178
512
  }
179
- // Only ever invoked from the "Cancel" button below, which itself only renders once `message` is
180
- // loaded and `isOutbox`/`message.scheduledSendTime` are both truthy — `draftsFolderUid` is required
181
- // by that same rendering guard (see the prop's own doc comment), so the non-null assertion reflects
182
- // a real invariant, matching `handleRecall`'s identical pattern just above.
513
+ // Only ever invoked from the "Cancel" button below (`isOutbox` and `message.scheduledSendTime` both truthy -
514
+ // `draftsFolderUid` is required whenever `isOutbox` is, see the prop's own doc comment), or from "Move to
515
+ // Drafts" (an Outbox message with no active schedule, which checks `draftsFolderUid` itself), so the non-null
516
+ // assertion reflects a real invariant, matching `handleRecall`'s identical pattern just above.
183
517
  async function handleCancelScheduledSend() {
184
518
  setCanceling(true);
185
519
  setCancelError(null);
186
520
  try {
187
521
  const updated = await cancelScheduledSend(message, draftsFolderUid);
522
+ // Keeps a `folder:`-scoped Tier 2 local search from still finding it in its old folder.
523
+ void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
188
524
  onScheduledSendCanceled?.(updated);
189
525
  }
190
526
  catch (err) {
191
- setCancelError(err instanceof ApiRequestError ? err.message : "Could not cancel this scheduled send.");
527
+ const fallback = message.scheduledSendTime ? "Could not cancel this scheduled send." : "Could not move this message to Drafts.";
528
+ setCancelError(err instanceof ApiRequestError ? err.message : fallback);
529
+ // A conflict or refusal usually means the send job claimed (or already relayed) the message meanwhile -
530
+ // re-read it so the pane shows "Sending..." or its new state instead of the stale controls.
531
+ if (err instanceof ApiRequestError && (err.status === 409 || err.status === 403)) {
532
+ void reloadMessage();
533
+ }
192
534
  }
193
535
  finally {
194
536
  setCanceling(false);
@@ -202,6 +544,7 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
202
544
  setArchiveError(null);
203
545
  try {
204
546
  const updated = await archiveMessage(message.uid);
547
+ void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
205
548
  onArchived?.(updated);
206
549
  }
207
550
  catch (err) {
@@ -215,13 +558,24 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
215
558
  // `message` is loaded — auto-saves on every toggle (no separate "Save" step), computing the full
216
559
  // new `labelUids` set from the message's current one since `setMessageLabels()` replaces the whole
217
560
  // list rather than patching a single entry.
561
+ //
562
+ // Toggles are serialized (every checkbox is disabled while one is in flight - React flushes a
563
+ // discrete input event's state update synchronously, so a second change can't slip in first), and
564
+ // each one reads the latest known copy (`latestLabelsMessageRef`, never a stale render closure) - so
565
+ // two quick toggles can never compute their full list, or send their optimistic-lock `version`, from
566
+ // the same stale snapshot.
218
567
  async function handleToggleLabel(labelUid) {
219
568
  setTogglingLabelUid(labelUid);
220
569
  setLabelsError(null);
221
570
  try {
222
- const current = message.labelUids ?? [];
571
+ // Read through the ref (not this render's closure) so a toggle fired from a not-yet-re-rendered
572
+ // handler still sees the previous toggle's server response.
573
+ const base = message.version >= latestLabelsMessageRef.current.version ? message : latestLabelsMessageRef.current;
574
+ const current = base.labelUids ?? [];
223
575
  const next = current.includes(labelUid) ? current.filter((uid) => uid !== labelUid) : [...current, labelUid];
224
- const updated = await setMessageLabels(message, next);
576
+ const updated = await setMessageLabels(base, next);
577
+ latestLabelsMessageRef.current = updated;
578
+ setLabelsMessage(updated);
225
579
  onLabelsChanged?.(updated);
226
580
  }
227
581
  catch (err) {
@@ -264,14 +618,65 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
264
618
  setReceiptBusy(null);
265
619
  }
266
620
  }
267
- return (_jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("div", { className: "border-b border-border p-4", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block mb-2", children: "\u2190 Back to messages" })), _jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight truncate", children: message.subject || "(no subject)" }), security && _jsx(SecurityIndicator, { state: security.state })] }), isSentItems &&
268
- (message.recallRequestedAt ? (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Recall requested" })) : (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setConfirming(true), children: "Recall this message" }))), isOutbox && message.scheduledSendTime && (_jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsxs("span", { className: "text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt", children: ["Scheduled for ", new Date(message.scheduledSendTime).toLocaleString()] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Cancel" })] }))] }), cancelError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: cancelError }) })), security?.headerTamperDetected && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: "This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or encrypted \u2014 an intermediary may have altered them after sending. Treat the fields shown above with caution." }) })), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["From ", message.from.displayName || message.from.address, " \u00B7", " ", new Date(message.receivedDate).toLocaleString()] }), _jsxs("p", { className: "text-sm text-text-muted", children: ["To ", message.recipients.map((r) => r.displayName || r.address).join(", ")] }), _jsxs("div", { className: "flex gap-2 mt-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleReply, children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleReplyAll, children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleForward, children: "Forward" }), !isOutbox && message.folderUid !== draftsFolderUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: archiving, disabled: archiving, onClick: handleArchive, children: "Archive" })), labels && labels.length > 0 && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setLabelsOpen(true), children: "Labels" }))] }), archiveError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: archiveError }) })), labels && (message.labelUids?.length ?? 0) > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 mt-2", children: message
621
+ const verified = security !== null && VERIFIED_STATES.has(security.state);
622
+ // A seal proves the signature verified when first opened, and the live check still passed everything but the signer
623
+ // key's status - so the signed Subject and attachments are shown as for a verified message, under its own badge.
624
+ const verifiedOrSealed = verified || security?.state === "verified_at_first_open";
625
+ // Under a verified badge, the Subject shown is the one the signature covers (RFC 9788 protected headers), when
626
+ // the message carries one - the outer Subject is unsigned and anyone relaying the message could change it.
627
+ const protectedSubject = verifiedOrSealed ? security.protectedHeaders?.subject : undefined;
628
+ // Only meaningful for signed-only mail: an encrypted message's outer Subject is deliberately obscured. Not a
629
+ // signature failure (mailing lists legitimately tag subjects), just worth pointing out.
630
+ const subjectDiffers = protectedSubject !== undefined && (security.state === "signed_verified" || security.sealedState === "signed_verified") && protectedSubject !== message.subject;
631
+ // Attachments inside the signed/decrypted entity. Under a verified badge only these are listed - the server's
632
+ // attachment records also include parts outside the signature, which the badge doesn't vouch for. For decrypted
633
+ // mail the server only ever saw the encrypted blob, so these are the real attachments.
634
+ // A decrypted message whose signature failed still only has these (the server list is just `smime.p7m`); they're
635
+ // listed with a warning. A signed-only failure never carries recovered attachments.
636
+ const innerAttachments = security?.attachments !== undefined &&
637
+ (verifiedOrSealed || security.state === "encrypted" || security.state === "encrypted_unverified_signer" || security.state === "signature_failed")
638
+ ? security.attachments
639
+ : undefined;
640
+ // Any state that involves a signature shows the address actually signed for (the protected From when the message
641
+ // carries one - it equals the outer From's address, or verification would have failed) next to the badge, never a
642
+ // display name alone: anyone can put "ceo@corp.com" in the name of a message sent from x@corp-pay.com.
643
+ const signatureShown = security !== null && security.state !== "unprotected" && security.state !== "encrypted";
644
+ const senderAddress = (signatureShown ? extractAddresses(security.protectedHeaders?.from)[0] : undefined) ?? message.from.address;
645
+ const senderName = message.from.displayName;
646
+ const senderNameCheck = checkSenderName(senderName, senderAddress);
647
+ const showSenderAddress = !!senderName && (signatureShown || senderNameCheck.looksLikeAddress);
648
+ const senderLabel = showSenderAddress ? `${senderName} <${senderAddress}>` : senderName || senderAddress;
649
+ // Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
650
+ // A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
651
+ const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
652
+ const trustableCertificate = security !== null && UNVERIFIED_SIGNER_STATES.has(security.state) && senderUnpinned && !pendingConflict
653
+ ? security.signerCertificate
654
+ : undefined;
655
+ const keyChanged = security && signerKeyChanged(security) ? security : undefined;
656
+ const pinnedSignerKey = senderKeyState?.pinned[0];
657
+ const recordedConflict = keyChanged && sameFingerprint(senderKeyState?.conflict?.observedKey.fingerprint, keyChanged.signerFingerprint)
658
+ ? senderKeyState.conflict
659
+ : undefined;
660
+ return (_jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("div", { className: "border-b border-border p-4", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block mb-2", children: "\u2190 Back to messages" })), _jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight truncate", children: (protectedSubject ?? message.subject) || "(no subject)" }), security && _jsx(SecurityIndicator, { security: security })] }), sendInProgress && (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Sending\u2026" })), isSentItems &&
661
+ (message.recallRequestedAt ? (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Recall requested" })) : (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setConfirming(true), children: "Recall this message" }))), inOutbox && !sendInProgress && message.scheduledSendTime && (_jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsxs("span", { className: "text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt", children: ["Scheduled for ", new Date(message.scheduledSendTime).toLocaleString()] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Cancel" })] })), inOutbox && !sendInProgress && !message.scheduledSendTime && draftsFolderUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Move to Drafts" }))] }), inOutbox && message.scheduledSendError && (_jsx("div", { className: "mt-2", children: _jsxs(Alert, { children: ["This message wasn\u2019t sent: ", message.scheduledSendError] }) })), cancelError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: cancelError }) })), keyChangeNotice && (_jsx("p", { role: "status", className: "mt-2 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: "mt-2 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: "mt-2 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", { className: "mt-1.5", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => {
662
+ setTrustError(null);
663
+ setTrustConfirmOpen(true);
664
+ }, children: "Trust this signer" }) })), subjectDiffers && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: ["The subject shown above is the one the sender signed. It differs from the subject this message was delivered with (\u201C", message.subject, "\u201D), which may have been changed on the way, e.g. by a mailing list."] })), security?.headerTamperDetected && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: "This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or encrypted \u2014 an intermediary may have altered them after sending. Treat the fields shown above with caution." }) })), security?.state === "verified_at_first_open" && (_jsx("p", { role: "status", className: `mt-2 py-2 px-3 rounded-sm text-sm text-text ${security.laterCompromised ? "bg-warning/15" : "bg-surface-alt"}`, children: verifiedAtFirstOpenMessage(security) })), keyChanged && (_jsxs("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", children: [_jsx("h2", { className: "font-semibold", children: "This sender\u2019s signing key changed" }), _jsxs("p", { children: ["The signature on this message is valid and its certificate names ", senderAddress, ", but it was made with a different key than the one you trust for this sender", keyChanged.state === "verified_at_first_open" ? "." : _jsx(_Fragment, { children: ", so it isn\u2019t verified." })] }), _jsx(KeyChangeReview, { mailboxUid: message.mailboxUid, address: senderAddress, useType: "sign", ownerName: "the sender", current: pinnedSignerKey && { fingerprint: pinnedSignerKey.fingerprint, since: senderKeyState.pinnedSince }, proposed: {
665
+ fingerprint: keyChanged.signerFingerprint,
666
+ emails: keyChanged.signerEmails,
667
+ observedAt: recordedConflict?.observedAt,
668
+ source: recordedConflict?.source,
669
+ }, certificate: keyChanged.signerCertificate, canReject: recordedConflict !== undefined, canResolve: canUpdateMailbox, onResolved: (action) => refreshAfterKeyChange(action === "reject" ? KEPT_CURRENT_SIGNING_KEY_MESSAGE : null), onPinnedKeyChanged: () => refreshAfterKeyChange(KEY_CHANGE_STALE_MESSAGE) })] })), security?.state === "signature_failed" && !keyChanged && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: security.signatureFailureReason
670
+ ? SIGNATURE_FAILURE_MESSAGE[security.signatureFailureReason]
671
+ : GENERIC_SIGNATURE_FAILURE_MESSAGE })), verifiedOrSealed && !security.protectedHeaders && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The signature covers this message\u2019s content and attachments only. Its Subject, To and Cc weren\u2019t signed, so they could have been changed after it was sent." })), security?.notAddressedToReader && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The recipients this message was signed or encrypted for don\u2019t include this mailbox - it may have been forwarded or re-sent to you unchanged, or you were Bcc\u2019d." })), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["From ", senderLabel, " \u00B7 ", new Date(message.receivedDate).toLocaleString()] }), 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."] })), _jsxs("p", { className: "text-sm text-text-muted", children: ["To ", message.recipients.map((r) => r.displayName || r.address).join(", ")] }), _jsxs("div", { className: "flex gap-2 mt-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleReply, children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleReplyAll, children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleForward, children: "Forward" }), !inOutbox && message.folderUid !== draftsFolderUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: archiving, disabled: archiving, onClick: handleArchive, children: "Archive" })), labels && labels.length > 0 && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setLabelsOpen(true), children: "Labels" }))] }), archiveError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: archiveError }) })), labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 mt-2", children: currentLabelsMessage
269
672
  .labelUids.map((uid) => labels.find((l) => l.uid === uid))
270
673
  .filter((l) => !!l)
271
674
  .map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), isInbox && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 mt-2", children: [_jsx(Button, { type: "button", variant: "text", loading: classifying, disabled: classifying, onClick: () => handleClassify(message.inferenceClassification === "other" ? "focused" : "other"), children: message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other" }), _jsxs("label", { className: "flex items-center gap-1.5 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: alwaysForSender, onChange: (e) => setAlwaysForSender(e.target.checked) }), "Always for this sender"] })] })), classifyError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: classifyError }) })), ["delivery", "read"]
272
675
  .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
273
- .map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [message.from.displayName || message.from.address, " 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("div", { className: "mt-2", children: _jsx(Alert, { children: receiptError }) })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 mt-3", children: attachments.map((attachment) => (_jsx("li", { children: _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))) }))] }), security?.decryptError && (_jsxs("div", { className: "px-4 pt-2", children: [_jsx(Alert, { children: security.decryptError }), message && !getUnlockedKeys(message.mailboxUid) && (_jsxs("button", { type: "button", onClick: handleUnlockToView, className: "mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to view this message"] }))] })), security?.html !== undefined ? (_jsx("iframe", { title: message.subject || "Message content", srcDoc: DOMPurify.sanitize(security.html), sandbox: "", className: "flex-1 w-full border-0" }, message.uid)) : (_jsx("iframe", { title: message.subject || "Message content", src: `/api/mail/messages/${encodeURIComponent(message.uid)}/content`, sandbox: "", className: "flex-1 w-full border-0" }, message.uid)), _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" })] })] }), _jsxs(Modal, { open: labelsOpen, onClose: () => setLabelsOpen(false), title: "Labels", children: [labelsError && _jsx(Alert, { children: labelsError }), _jsx("ul", { className: "flex flex-col gap-1", children: (labels ?? []).map((l) => {
274
- const checked = message.labelUids?.includes(l.uid) ?? false;
275
- return (_jsx("li", { children: _jsxs("label", { className: "flex items-center gap-2 py-1.5 px-1 text-sm rounded-sm hover:bg-surface-alt", children: [_jsx("input", { type: "checkbox", checked: checked, disabled: togglingLabelUid === l.uid, onChange: () => handleToggleLabel(l.uid) }), _jsx("span", { className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }) }, l.uid));
676
+ .map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 mt-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("div", { className: "mt-2", children: _jsx(Alert, { children: receiptError }) })), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "mt-3 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
677
+ ? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 mt-3", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: _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 ?? ""}`))) }))
678
+ : attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 mt-3", children: attachments.map((attachment) => (_jsx("li", { children: _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))) }))] }), security?.decryptError && (_jsxs("div", { className: "px-4 pt-2", children: [_jsx(Alert, { children: security.decryptError }), !getUnlockedKeys(message.mailboxUid) && (_jsxs("button", { type: "button", onClick: handleUnlockToView, className: "mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to view this message"] }))] })), security?.text !== undefined ? (_jsx("pre", { "aria-label": message.subject || "Message content", className: "flex-1 w-full overflow-auto p-4 m-0 text-sm font-sans whitespace-pre-wrap break-words", children: security.text })) : security?.html !== undefined ? (_jsx("iframe", { title: message.subject || "Message content", srcDoc: buildSecureSrcDoc(security.html), sandbox: "", className: "flex-1 w-full border-0" }, message.uid)) : (_jsx("iframe", { title: message.subject || "Message content", src: `/api/mail/messages/${encodeURIComponent(message.uid)}/content`, sandbox: "", className: "flex-1 w-full border-0" }, message.uid)), _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" })] })] }) }), _jsxs(Modal, { open: labelsOpen, onClose: () => setLabelsOpen(false), title: "Labels", children: [labelsError && _jsx(Alert, { children: labelsError }), _jsx("ul", { className: "flex flex-col gap-1", children: (labels ?? []).map((l) => {
679
+ const checked = currentLabelsMessage.labelUids?.includes(l.uid) ?? false;
680
+ return (_jsx("li", { children: _jsxs("label", { className: "flex items-center gap-2 py-1.5 px-1 text-sm rounded-sm hover:bg-surface-alt", children: [_jsx("input", { type: "checkbox", checked: checked, disabled: togglingLabelUid !== null, onChange: () => handleToggleLabel(l.uid) }), _jsx("span", { className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }) }, l.uid));
276
681
  }) })] })] }));
277
682
  }