@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,630 +1,1318 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useEffect, useState } from "react";
6
- import { HiOutlineLockClosed } from "react-icons/hi2";
7
- import DOMPurify from "dompurify";
8
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
- import {
10
- Attachment,
11
- Message,
12
- MessageClassification,
13
- ReceiptType,
14
- approveReceipt,
15
- archiveMessage,
16
- attachmentContentUrl,
17
- cancelScheduledSend,
18
- classifyMessage,
19
- declineReceipt,
20
- getMessageRawContent,
21
- recallMessage,
22
- setMessageLabels,
23
- } from "@rapidmx/react-shared/mail/mailApi.js";
24
- import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
25
- import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
26
- import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
27
- import { MessageSecurityResult, evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
28
- import { isLikelyMailingList } from "@rapidmx/react-shared/crypto/composeSecurity.js";
29
- import { useCompose } from "./compose/ComposeContext.js";
30
- import { useMailShell } from "./layout/MailShell.js";
31
- import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
32
- import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
33
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
34
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
35
-
36
- /** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
37
- * plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
38
- * read as a muted variant of "verified" (a failed signature is a stronger negative than no signature at
39
- * all), and "Unprotected" MUST NOT read as an error - the class pairs below are chosen so those two
40
- * never share styling with each other or with the verified states. */
41
- const SECURITY_INDICATOR: Record<MessageSecurityResult["state"], { label: string; className: string }> = {
42
- unprotected: { label: "Unprotected", className: "bg-surface-alt text-text-muted" },
43
- encrypted: { label: "Encrypted", className: "bg-primary/10 text-primary-dark" },
44
- signed_verified: { label: "Signed & verified", className: "bg-success/10 text-success" },
45
- encrypted_verified: { label: "Encrypted & verified", className: "bg-success/10 text-success" },
46
- signature_failed: { label: "Signature failed", className: "bg-danger-bg text-danger" },
47
- };
48
-
49
- function SecurityIndicator({ state }: { state: MessageSecurityResult["state"] }) {
50
- const { label, className } = SECURITY_INDICATOR[state];
51
- return <span className={`text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`}>{label}</span>;
52
- }
53
-
54
- export interface MessageDetailPaneProps {
55
- message: Message | null;
56
- attachments: Attachment[];
57
- /** Present only on the mobile detail route — renders a "back to messages" link above the header. Absent
58
- * on the desktop reading pane, which never navigates away (selecting a different message just swaps
59
- * `message` in place). */
60
- backHref?: string;
61
- /** Whether `message` currently lives in Sent Items — the only folder recall is offered from, matching
62
- * Outlook's own restriction (and `BaseMessageRoute.recall()`'s own server-side check). Each caller
63
- * computes this from its own already-loaded folder list rather than this component fetching folders
64
- * itself. */
65
- isSentItems?: boolean;
66
- /** Called with the server's updated copy (carrying `recallRequestedAt`) after a successful recall, so
67
- * the caller can patch its own in-memory message/list state — mirrors `mailDetailHooks.ts`'s
68
- * `useMarkMessageRead`'s identical `onUpdated` callback. */
69
- onRecalled?: (updated: Message) => void;
70
- /** Whether `message` currently lives in Outbox — the only folder a scheduled send can be canceled
71
- * from. Each caller computes this the same way it computes `isSentItems`. */
72
- isOutbox?: boolean;
73
- /** The mailbox's Drafts folder uid — required when `isOutbox` is `true`, since canceling a
74
- * scheduled send moves the message back into Drafts (see `cancelScheduledSend()`'s own doc comment
75
- * on why clearing `scheduledSendTime` alone doesn't do that). */
76
- draftsFolderUid?: string;
77
- /** Called with the server's updated copy (now back in Drafts, `scheduledSendTime` cleared) after
78
- * successfully canceling a scheduled send. */
79
- onScheduledSendCanceled?: (updated: Message) => void;
80
- /** Whether `message` currently lives in the Inbox — Focused/Other classification is an Inbox-only
81
- * concept (`FocusedInboxUtils.classifyMessage()` short-circuits to Focused for every other folder), so
82
- * the "Move to Other"/"Move to Focused" control below only renders here, the same
83
- * each-caller-computes-its-own-folder-type pattern `isSentItems`/`isOutbox` already use. */
84
- isInbox?: boolean;
85
- /** Called with the server's updated copy (carrying the new `inferenceClassification`) after a
86
- * successful classify — mirrors `onRecalled`'s identical shape. */
87
- onClassified?: (updated: Message) => void;
88
- /** Called with the server's updated copy after approving/declining a pending delivery/read receipt —
89
- * mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`/
90
- * `isInbox`): `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are
91
- * only ever `true` on a real delivered copy, so the banner below is self-gating. */
92
- onReceiptHandled?: (updated: Message) => void;
93
- /** Called with the server's updated copy (now filed under the mailbox's Archive folder) after a
94
- * successful archive — mirrors `onRecalled`'s identical shape. Archiving itself is offered for any
95
- * message except one currently in Drafts or Outbox (mirrors `BaseMessageRoute.archive()`'s own
96
- * server-side 400 guard); Drafts is detected via `draftsFolderUid` (already passed by every caller
97
- * for the Outbox-cancel flow) rather than a new prop. */
98
- onArchived?: (updated: Message) => void;
99
- /** Every label defined in this message's mailbox, for the label-assignment popover below — each
100
- * caller fetches its own mailbox's labels the same way it already resolves `draftsFolderUid`
101
- * (`listLabels()`). Absent/empty simply hides the Labels control - there's nothing to assign. */
102
- labels?: Label[];
103
- /** Called with the server's updated copy (carrying the new `labelUids`) after successfully toggling
104
- * a label — always patches in place, never removes from a caller's list (unlike `onArchived`):
105
- * changing labels never moves a message between folders. */
106
- onLabelsChanged?: (updated: Message) => void;
107
- }
108
-
109
- function formatBytes(bytes: number): string {
110
- if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
111
- if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
112
- return `${bytes} B`;
113
- }
114
-
115
- /**
116
- * A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
117
- * Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
118
- * the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
119
- * a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
120
- * site for how `message`/`attachments`/`isSentItems` are sourced.
121
- */
122
- export default function MessageDetailPane({
123
- message,
124
- attachments,
125
- backHref,
126
- isSentItems,
127
- onRecalled,
128
- isOutbox,
129
- draftsFolderUid,
130
- onScheduledSendCanceled,
131
- isInbox,
132
- onClassified,
133
- onReceiptHandled,
134
- onArchived,
135
- labels,
136
- onLabelsChanged,
137
- }: MessageDetailPaneProps) {
138
- const { openCompose } = useCompose();
139
- const [confirming, setConfirming] = useState(false);
140
- const [recalling, setRecalling] = useState(false);
141
- const [canceling, setCanceling] = useState(false);
142
- const [archiving, setArchiving] = useState(false);
143
- const [archiveError, setArchiveError] = useState<string | null>(null);
144
- const [labelsOpen, setLabelsOpen] = useState(false);
145
- const [togglingLabelUid, setTogglingLabelUid] = useState<string | null>(null);
146
- const [labelsError, setLabelsError] = useState<string | null>(null);
147
- const [error, setError] = useState<string | null>(null);
148
- // Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
149
- // pane rather than inside a confirmation modal — the two flows never need to share one message.
150
- const [cancelError, setCancelError] = useState<string | null>(null);
151
- const [classifying, setClassifying] = useState(false);
152
- const [classifyError, setClassifyError] = useState<string | null>(null);
153
- const [alwaysForSender, setAlwaysForSender] = useState(false);
154
- // Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
155
- // `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
156
- // wouldn't distinguish which row's buttons should show a loading state.
157
- const [receiptBusy, setReceiptBusy] = useState<ReceiptType | null>(null);
158
- const [receiptError, setReceiptError] = useState<string | null>(null);
159
- const [security, setSecurity] = useState<MessageSecurityResult | null>(null);
160
- const { mailboxes } = useMailShell();
161
- const { requestUnlock } = useUnlockPrompt();
162
- // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
163
- // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
164
- // state; see keySession.ts's own doc comment).
165
- const [unlockRefresh, setUnlockRefresh] = useState(0);
166
-
167
- // Evaluates every message's security state, not just ones flagged `encrypted` - a detached
168
- // `multipart/signed` message needs its raw MIME read too (a sanitized HTML body never carries the
169
- // signature part) to tell "Signed & verified" apart from plain "Unprotected". A fetch/parse failure
170
- // degrades to "Unprotected" rather than surfacing an error - the spec treats that as this module's
171
- // safe default, not a distinct failure state of its own.
172
- useEffect(() => {
173
- if (!message) {
174
- setSecurity(null);
175
- return;
176
- }
177
- let cancelled = false;
178
- setSecurity(null);
179
- getMessageRawContent(message.uid)
180
- .then((rawMime) => evaluateMessageSecurity(rawMime, getUnlockedKeys(message.mailboxUid)))
181
- .then((result) => {
182
- if (!cancelled) {
183
- setSecurity(result);
184
- }
185
- })
186
- .catch(() => {
187
- if (!cancelled) {
188
- setSecurity({ state: "unprotected" });
189
- }
190
- });
191
- return () => {
192
- cancelled = true;
193
- };
194
- }, [message?.uid, unlockRefresh]);
195
-
196
- // Offered only when this device genuinely has no unlocked session for this message's mailbox at all
197
- // (as opposed to being unlocked but still unable to decrypt - a wrong/since-rotated key, which
198
- // re-unlocking the same session can't fix) - see `evaluateMessageSecurity()`'s own doc comment on why
199
- // `decryptError` alone can't distinguish those two cases.
200
- async function handleUnlockToView() {
201
- // Only reachable via the button below, which never renders while `message` is null.
202
- const mailboxUid = message!.mailboxUid;
203
- const mailboxKeys = mailboxes.find((mb) => mb.uid === mailboxUid)?.keys ?? [];
204
- try {
205
- await requestUnlock(mailboxUid, mailboxKeys);
206
- setUnlockRefresh((n) => n + 1);
207
- } catch {
208
- // User dismissed the unlock dialog - security state stays exactly as it was.
209
- }
210
- }
211
-
212
- if (!message) {
213
- return <p className="p-8 text-sm text-text-muted">Select a message to read it.</p>;
214
- }
215
-
216
- // Only ever invoked from the Reply/Reply All/Forward buttons below, which themselves only render
217
- // once `message` is loaded (the early return above covers the only other state) — the non-null
218
- // assertions reflect that real invariant, matching `handleRecall`'s identical pattern just below.
219
- function handleReply() {
220
- openCompose({
221
- mailboxUid: message!.mailboxUid,
222
- to: message!.from.address,
223
- subject: replySubject(message!.subject),
224
- quotedHtml: buildReplyQuote(message!),
225
- signatureContext: "reply_forward",
226
- suppressSigning: isLikelyMailingList({ listUnsubscribe: message!.listUnsubscribeHeader }),
227
- });
228
- }
229
-
230
- function handleReplyAll() {
231
- const cc = message!.recipients.filter((r) => r.type !== "bcc").map((r) => r.address);
232
- openCompose({
233
- mailboxUid: message!.mailboxUid,
234
- to: message!.from.address,
235
- cc: cc.join(", "),
236
- subject: replySubject(message!.subject),
237
- quotedHtml: buildReplyQuote(message!),
238
- signatureContext: "reply_forward",
239
- suppressSigning: isLikelyMailingList({ listUnsubscribe: message!.listUnsubscribeHeader }),
240
- });
241
- }
242
-
243
- function handleForward() {
244
- openCompose({
245
- mailboxUid: message!.mailboxUid,
246
- subject: forwardSubject(message!.subject),
247
- quotedHtml: buildForwardQuote(message!),
248
- signatureContext: "reply_forward",
249
- });
250
- }
251
-
252
- async function handleRecall() {
253
- // Only ever invoked from the confirmation modal below, which itself only renders once `message`
254
- // is loaded (the early return above covers the only other state) — the non-null assertion
255
- // reflects that real invariant, matching this codebase's established pattern for the same class
256
- // of always-true-in-practice guard.
257
- setRecalling(true);
258
- setError(null);
259
- try {
260
- const updated = await recallMessage(message!.uid);
261
- setConfirming(false);
262
- onRecalled?.(updated);
263
- } catch (err) {
264
- setError(err instanceof ApiRequestError ? err.message : "Could not recall this message.");
265
- } finally {
266
- setRecalling(false);
267
- }
268
- }
269
-
270
- // Only ever invoked from the "Cancel" button below, which itself only renders once `message` is
271
- // loaded and `isOutbox`/`message.scheduledSendTime` are both truthy — `draftsFolderUid` is required
272
- // by that same rendering guard (see the prop's own doc comment), so the non-null assertion reflects
273
- // a real invariant, matching `handleRecall`'s identical pattern just above.
274
- async function handleCancelScheduledSend() {
275
- setCanceling(true);
276
- setCancelError(null);
277
- try {
278
- const updated = await cancelScheduledSend(message!, draftsFolderUid!);
279
- onScheduledSendCanceled?.(updated);
280
- } catch (err) {
281
- setCancelError(err instanceof ApiRequestError ? err.message : "Could not cancel this scheduled send.");
282
- } finally {
283
- setCanceling(false);
284
- }
285
- }
286
-
287
- // Only ever invoked from the Archive button below, which itself only renders once `message` is
288
- // loaded and this isn't a Drafts/Outbox message (the button's own guard mirrors
289
- // `BaseMessageRoute.archive()`'s server-side 400) — same real invariant as `handleRecall` above.
290
- async function handleArchive() {
291
- setArchiving(true);
292
- setArchiveError(null);
293
- try {
294
- const updated = await archiveMessage(message!.uid);
295
- onArchived?.(updated);
296
- } catch (err) {
297
- setArchiveError(err instanceof ApiRequestError ? err.message : "Could not archive this message.");
298
- } finally {
299
- setArchiving(false);
300
- }
301
- }
302
-
303
- // Only ever invoked from a checkbox in the Labels popover below, which itself only renders once
304
- // `message` is loaded — auto-saves on every toggle (no separate "Save" step), computing the full
305
- // new `labelUids` set from the message's current one since `setMessageLabels()` replaces the whole
306
- // list rather than patching a single entry.
307
- async function handleToggleLabel(labelUid: string) {
308
- setTogglingLabelUid(labelUid);
309
- setLabelsError(null);
310
- try {
311
- const current = message!.labelUids ?? [];
312
- const next = current.includes(labelUid) ? current.filter((uid) => uid !== labelUid) : [...current, labelUid];
313
- const updated = await setMessageLabels(message!, next);
314
- onLabelsChanged?.(updated);
315
- } catch (err) {
316
- setLabelsError(err instanceof ApiRequestError ? err.message : "Could not update this message's labels.");
317
- } finally {
318
- setTogglingLabelUid(null);
319
- }
320
- }
321
-
322
- // Only ever invoked from the classification button below, which itself only renders once `message`
323
- // is loaded and `isInbox` is true — the non-null assertion reflects the same real invariant as
324
- // `handleRecall`/`handleCancelScheduledSend` above.
325
- async function handleClassify(classifyAs: MessageClassification) {
326
- setClassifying(true);
327
- setClassifyError(null);
328
- try {
329
- const updated = await classifyMessage(message!.uid, classifyAs, alwaysForSender);
330
- onClassified?.(updated);
331
- } catch (err) {
332
- setClassifyError(err instanceof ApiRequestError ? err.message : "Could not reclassify this message.");
333
- } finally {
334
- setClassifying(false);
335
- }
336
- }
337
-
338
- // Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
339
- // is loaded — same real invariant as every other handler above.
340
- async function handleReceipt(type: ReceiptType, action: "approve" | "decline") {
341
- setReceiptBusy(type);
342
- setReceiptError(null);
343
- try {
344
- const updated = await (action === "approve" ? approveReceipt : declineReceipt)(message!.uid, type);
345
- onReceiptHandled?.(updated);
346
- } catch (err) {
347
- setReceiptError(err instanceof ApiRequestError ? err.message : "Could not handle this receipt request.");
348
- } finally {
349
- setReceiptBusy(null);
350
- }
351
- }
352
-
353
- return (
354
- <div className="flex-1 min-w-0 flex flex-col">
355
- <div className="border-b border-border p-4">
356
- {backHref && (
357
- <a href={backHref} className="text-sm text-primary-dark hover:underline block mb-2">
358
- &larr; Back to messages
359
- </a>
360
- )}
361
- <div className="flex items-start justify-between gap-3">
362
- <div className="flex items-center gap-2 min-w-0">
363
- <h1 className="text-lg font-bold tracking-tight truncate">{message.subject || "(no subject)"}</h1>
364
- {security && <SecurityIndicator state={security.state} />}
365
- </div>
366
- {isSentItems &&
367
- (message.recallRequestedAt ? (
368
- <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
369
- Recall requested
370
- </span>
371
- ) : (
372
- <Button
373
- type="button"
374
- variant="secondary"
375
- className="!w-auto shrink-0"
376
- onClick={() => setConfirming(true)}
377
- >
378
- Recall this message
379
- </Button>
380
- ))}
381
- {isOutbox && message.scheduledSendTime && (
382
- <div className="flex items-center gap-2 shrink-0">
383
- <span className="text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt">
384
- Scheduled for {new Date(message.scheduledSendTime).toLocaleString()}
385
- </span>
386
- <Button
387
- type="button"
388
- variant="secondary"
389
- className="!w-auto"
390
- loading={canceling}
391
- disabled={canceling}
392
- onClick={handleCancelScheduledSend}
393
- >
394
- Cancel
395
- </Button>
396
- </div>
397
- )}
398
- </div>
399
- {cancelError && (
400
- <div className="mt-2">
401
- <Alert>{cancelError}</Alert>
402
- </div>
403
- )}
404
- {security?.headerTamperDetected && (
405
- <div className="mt-2">
406
- <Alert>
407
- This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or
408
- encrypted — an intermediary may have altered them after sending. Treat the fields shown above with
409
- caution.
410
- </Alert>
411
- </div>
412
- )}
413
- <p className="text-sm text-text-muted mt-1">
414
- From {message.from.displayName || message.from.address} &middot;{" "}
415
- {new Date(message.receivedDate).toLocaleString()}
416
- </p>
417
- <p className="text-sm text-text-muted">
418
- To {message.recipients.map((r) => r.displayName || r.address).join(", ")}
419
- </p>
420
- <div className="flex gap-2 mt-3">
421
- <Button type="button" variant="secondary" className="!w-auto" onClick={handleReply}>
422
- Reply
423
- </Button>
424
- <Button type="button" variant="secondary" className="!w-auto" onClick={handleReplyAll}>
425
- Reply All
426
- </Button>
427
- <Button type="button" variant="secondary" className="!w-auto" onClick={handleForward}>
428
- Forward
429
- </Button>
430
- {!isOutbox && message.folderUid !== draftsFolderUid && (
431
- <Button
432
- type="button"
433
- variant="secondary"
434
- className="!w-auto"
435
- loading={archiving}
436
- disabled={archiving}
437
- onClick={handleArchive}
438
- >
439
- Archive
440
- </Button>
441
- )}
442
- {labels && labels.length > 0 && (
443
- <Button type="button" variant="secondary" className="!w-auto" onClick={() => setLabelsOpen(true)}>
444
- Labels
445
- </Button>
446
- )}
447
- </div>
448
- {archiveError && (
449
- <div className="mt-2">
450
- <Alert>{archiveError}</Alert>
451
- </div>
452
- )}
453
- {labels && (message.labelUids?.length ?? 0) > 0 && (
454
- <div className="flex flex-wrap items-center gap-1.5 mt-2">
455
- {message
456
- .labelUids!.map((uid) => labels.find((l) => l.uid === uid))
457
- .filter((l): l is Label => !!l)
458
- .map((l) => (
459
- <span
460
- key={l.uid}
461
- className="inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt"
462
- >
463
- <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
464
- {l.name}
465
- </span>
466
- ))}
467
- </div>
468
- )}
469
- {isInbox && (
470
- <div className="flex flex-wrap items-center gap-2 mt-2">
471
- <Button
472
- type="button"
473
- variant="text"
474
- loading={classifying}
475
- disabled={classifying}
476
- onClick={() =>
477
- handleClassify(message.inferenceClassification === "other" ? "focused" : "other")
478
- }
479
- >
480
- {message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other"}
481
- </Button>
482
- <label className="flex items-center gap-1.5 text-xs text-text-muted">
483
- <input
484
- type="checkbox"
485
- checked={alwaysForSender}
486
- onChange={(e) => setAlwaysForSender(e.target.checked)}
487
- />
488
- Always for this sender
489
- </label>
490
- </div>
491
- )}
492
- {classifyError && (
493
- <div className="mt-2">
494
- <Alert>{classifyError}</Alert>
495
- </div>
496
- )}
497
- {(["delivery", "read"] as const)
498
- .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
499
- .map((type) => (
500
- <div
501
- key={type}
502
- className="flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm"
503
- >
504
- <span>
505
- {message.from.displayName || message.from.address} requested a {type} receipt for this
506
- message.
507
- </span>
508
- <Button
509
- type="button"
510
- variant="secondary"
511
- className="!w-auto"
512
- loading={receiptBusy === type}
513
- disabled={receiptBusy !== null}
514
- onClick={() => handleReceipt(type, "approve")}
515
- >
516
- Send receipt
517
- </Button>
518
- <Button
519
- type="button"
520
- variant="text"
521
- disabled={receiptBusy !== null}
522
- onClick={() => handleReceipt(type, "decline")}
523
- >
524
- Decline
525
- </Button>
526
- </div>
527
- ))}
528
- {receiptError && (
529
- <div className="mt-2">
530
- <Alert>{receiptError}</Alert>
531
- </div>
532
- )}
533
- {attachments.length > 0 && (
534
- <ul className="flex flex-wrap gap-2 mt-3">
535
- {attachments.map((attachment) => (
536
- <li key={attachment.uid}>
537
- <a
538
- href={attachmentContentUrl(attachment.uid)}
539
- className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
540
- >
541
- {attachment.filename} ({formatBytes(attachment.sizeBytes)})
542
- </a>
543
- </li>
544
- ))}
545
- </ul>
546
- )}
547
- </div>
548
- {security?.decryptError && (
549
- <div className="px-4 pt-2">
550
- <Alert>{security.decryptError}</Alert>
551
- {message && !getUnlockedKeys(message.mailboxUid) && (
552
- <button
553
- type="button"
554
- onClick={handleUnlockToView}
555
- className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
556
- >
557
- <HiOutlineLockClosed size={12} aria-hidden="true" />
558
- Unlock to view this message
559
- </button>
560
- )}
561
- </div>
562
- )}
563
- {security?.html !== undefined ? (
564
- // A decrypted/verified body never came through the server's own sanitize-html pass (it
565
- // couldn't - the server never saw the plaintext) - DOMPurify sanitizes it here, client-side,
566
- // before it ever touches the DOM, on top of (not instead of) the iframe's own `sandbox=""`.
567
- <iframe
568
- key={message.uid}
569
- title={message.subject || "Message content"}
570
- srcDoc={DOMPurify.sanitize(security.html)}
571
- sandbox=""
572
- className="flex-1 w-full border-0"
573
- />
574
- ) : (
575
- <iframe
576
- key={message.uid}
577
- title={message.subject || "Message content"}
578
- src={`/api/mail/messages/${encodeURIComponent(message.uid)}/content`}
579
- sandbox=""
580
- className="flex-1 w-full border-0"
581
- />
582
- )}
583
-
584
- <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
585
- <p className="text-sm text-text-muted mb-4">
586
- This asks every original recipient's mail system to delete their copy, but only if it's
587
- still unread there — there's no way to guarantee it, and no confirmation once it either
588
- succeeds or fails. Recipients who already read the message will keep it.
589
- </p>
590
- {error && <Alert>{error}</Alert>}
591
- <div className="flex gap-3">
592
- <Button type="button" loading={recalling} disabled={recalling} onClick={handleRecall} className="!w-auto">
593
- Recall message
594
- </Button>
595
- <Button
596
- type="button"
597
- variant="secondary"
598
- disabled={recalling}
599
- onClick={() => setConfirming(false)}
600
- className="!w-auto"
601
- >
602
- Cancel
603
- </Button>
604
- </div>
605
- </Modal>
606
- <Modal open={labelsOpen} onClose={() => setLabelsOpen(false)} title="Labels">
607
- {labelsError && <Alert>{labelsError}</Alert>}
608
- <ul className="flex flex-col gap-1">
609
- {(labels ?? []).map((l) => {
610
- const checked = message.labelUids?.includes(l.uid) ?? false;
611
- return (
612
- <li key={l.uid}>
613
- <label className="flex items-center gap-2 py-1.5 px-1 text-sm rounded-sm hover:bg-surface-alt">
614
- <input
615
- type="checkbox"
616
- checked={checked}
617
- disabled={togglingLabelUid === l.uid}
618
- onChange={() => handleToggleLabel(l.uid)}
619
- />
620
- <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
621
- {l.name}
622
- </label>
623
- </li>
624
- );
625
- })}
626
- </ul>
627
- </Modal>
628
- </div>
629
- );
630
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useRef, useState } from "react";
6
+ import { HiOutlineCheck, HiOutlineExclamationTriangle, HiOutlineLockClosed } from "react-icons/hi2";
7
+ import DOMPurify from "dompurify";
8
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
+ import {
10
+ Attachment,
11
+ Message,
12
+ MessageClassification,
13
+ ReceiptType,
14
+ approveReceipt,
15
+ archiveMessage,
16
+ attachmentContentUrl,
17
+ cancelScheduledSend,
18
+ classifyMessage,
19
+ declineReceipt,
20
+ getMessage,
21
+ getMessageRawContent,
22
+ recallMessage,
23
+ setMessageLabels,
24
+ } from "@rapidmx/react-shared/mail/mailApi.js";
25
+ import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
26
+ import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
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";
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";
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";
46
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
47
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
48
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
49
+
50
+ /** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
51
+ * plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
52
+ * read as a muted variant of "verified" (a failed signature is a stronger negative than no signature at
53
+ * all), and "Unprotected" MUST NOT read as an error - the class pairs below are chosen so those two
54
+ * never share styling with each other or with the verified states. */
55
+ const SECURITY_INDICATOR: Record<MessageSecurityResult["state"], { label: string; className: string }> = {
56
+ unprotected: { label: "Unprotected", className: "bg-surface-alt text-text-muted" },
57
+ encrypted: { label: "Encrypted", className: "bg-primary/10 text-primary-dark" },
58
+ signed_verified: { label: "Signed & verified", className: "bg-success/10 text-success" },
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" },
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" },
68
+ };
69
+
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;
257
+ }
258
+
259
+ export interface MessageDetailPaneProps {
260
+ message: Message | null;
261
+ attachments: Attachment[];
262
+ /** Present only on the mobile detail route — renders a "back to messages" link above the header. Absent
263
+ * on the desktop reading pane, which never navigates away (selecting a different message just swaps
264
+ * `message` in place). */
265
+ backHref?: string;
266
+ /** Whether `message` currently lives in Sent Items — the only folder recall is offered from, matching
267
+ * Outlook's own restriction (and `BaseMessageRoute.recall()`'s own server-side check). Each caller
268
+ * computes this from its own already-loaded folder list rather than this component fetching folders
269
+ * itself. */
270
+ isSentItems?: boolean;
271
+ /** Called with the server's updated copy (carrying `recallRequestedAt`) after a successful recall, so
272
+ * the caller can patch its own in-memory message/list state — mirrors `mailDetailHooks.ts`'s
273
+ * `useMarkMessageRead`'s identical `onUpdated` callback. */
274
+ onRecalled?: (updated: Message) => void;
275
+ /** Whether `message` currently lives in Outbox — the only folder a scheduled send can be canceled
276
+ * from. Each caller computes this the same way it computes `isSentItems`. */
277
+ isOutbox?: boolean;
278
+ /** The mailbox's Drafts folder uid — required when `isOutbox` is `true`, since canceling a
279
+ * scheduled send moves the message back into Drafts (see `cancelScheduledSend()`'s own doc comment
280
+ * on why clearing `scheduledSendTime` alone doesn't do that). */
281
+ draftsFolderUid?: string;
282
+ /** Called with the server's updated copy (now back in Drafts, `scheduledSendTime` cleared) after
283
+ * successfully canceling a scheduled send. */
284
+ onScheduledSendCanceled?: (updated: Message) => void;
285
+ /** Whether `message` currently lives in the Inbox — Focused/Other classification is an Inbox-only
286
+ * concept (`FocusedInboxUtils.classifyMessage()` short-circuits to Focused for every other folder), so
287
+ * the "Move to Other"/"Move to Focused" control below only renders here, the same
288
+ * each-caller-computes-its-own-folder-type pattern `isSentItems`/`isOutbox` already use. */
289
+ isInbox?: boolean;
290
+ /** Called with the server's updated copy (carrying the new `inferenceClassification`) after a
291
+ * successful classify — mirrors `onRecalled`'s identical shape. */
292
+ onClassified?: (updated: Message) => void;
293
+ /** Called with the server's updated copy after approving/declining a pending delivery/read receipt —
294
+ * mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`/
295
+ * `isInbox`): `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are
296
+ * only ever `true` on a real delivered copy, so the banner below is self-gating. */
297
+ onReceiptHandled?: (updated: Message) => void;
298
+ /** Called with the server's updated copy (now filed under the mailbox's Archive folder) after a
299
+ * successful archive — mirrors `onRecalled`'s identical shape. Archiving itself is offered for any
300
+ * message except one currently in Drafts or Outbox (mirrors `BaseMessageRoute.archive()`'s own
301
+ * server-side 400 guard); Drafts is detected via `draftsFolderUid` (already passed by every caller
302
+ * for the Outbox-cancel flow) rather than a new prop. */
303
+ onArchived?: (updated: Message) => void;
304
+ /** Every label defined in this message's mailbox, for the label-assignment popover below — each
305
+ * caller fetches its own mailbox's labels the same way it already resolves `draftsFolderUid`
306
+ * (`listLabels()`). Absent/empty simply hides the Labels control - there's nothing to assign. */
307
+ labels?: Label[];
308
+ /** Called with the server's updated copy (carrying the new `labelUids`) after successfully toggling
309
+ * a label — always patches in place, never removes from a caller's list (unlike `onArchived`):
310
+ * changing labels never moves a message between folders. */
311
+ onLabelsChanged?: (updated: Message) => void;
312
+ }
313
+
314
+ function formatBytes(bytes: number): string {
315
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
316
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
317
+ return `${bytes} B`;
318
+ }
319
+
320
+ /**
321
+ * A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
322
+ * Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
323
+ * the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
324
+ * a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
325
+ * site for how `message`/`attachments`/`isSentItems` are sourced.
326
+ */
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,
347
+ attachments,
348
+ backHref,
349
+ isSentItems,
350
+ onRecalled,
351
+ isOutbox,
352
+ draftsFolderUid,
353
+ onScheduledSendCanceled,
354
+ isInbox,
355
+ onClassified,
356
+ onReceiptHandled,
357
+ onArchived,
358
+ labels,
359
+ onLabelsChanged,
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;
368
+ const { openCompose } = useCompose();
369
+ const [confirming, setConfirming] = useState(false);
370
+ const [recalling, setRecalling] = useState(false);
371
+ const [canceling, setCanceling] = useState(false);
372
+ const [archiving, setArchiving] = useState(false);
373
+ const [archiveError, setArchiveError] = useState<string | null>(null);
374
+ const [labelsOpen, setLabelsOpen] = useState(false);
375
+ const [togglingLabelUid, setTogglingLabelUid] = useState<string | null>(null);
376
+ const [labelsError, setLabelsError] = useState<string | null>(null);
377
+ const [error, setError] = useState<string | null>(null);
378
+ // Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
379
+ // pane rather than inside a confirmation modal — the two flows never need to share one message.
380
+ const [cancelError, setCancelError] = useState<string | null>(null);
381
+ const [classifying, setClassifying] = useState(false);
382
+ const [classifyError, setClassifyError] = useState<string | null>(null);
383
+ const [alwaysForSender, setAlwaysForSender] = useState(false);
384
+ // Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
385
+ // `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
386
+ // wouldn't distinguish which row's buttons should show a loading state.
387
+ const [receiptBusy, setReceiptBusy] = useState<ReceiptType | null>(null);
388
+ const [receiptError, setReceiptError] = useState<string | null>(null);
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(" ") : "";
426
+
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()`).
436
+ useEffect(() => {
437
+ if (!rawEvaluationNeeded) {
438
+ setSecurity({ state: "unprotected" });
439
+ return;
440
+ }
441
+ let cancelled = false;
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;
463
+ getMessageRawContent(message.uid)
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
+ ]);
517
+ if (!cancelled) {
518
+ setSecurity(result);
519
+ setSenderUnpinned(unpinned);
520
+ setSenderKeyState(keyState);
521
+ setCanUpdateMailbox(access);
522
+ }
523
+ })
524
+ .catch(() => {
525
+ if (!cancelled) {
526
+ setSecurity(
527
+ message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
528
+ );
529
+ }
530
+ });
531
+ return () => {
532
+ cancelled = true;
533
+ };
534
+ }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey]);
535
+
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);
614
+ }
615
+
616
+ // Only ever invoked from the Reply/Reply All/Forward buttons below, which themselves only render
617
+ // once `message` is loaded (the early return above covers the only other state) — the non-null
618
+ // assertions reflect that real invariant, matching `handleRecall`'s identical pattern just below.
619
+ function handleReply() {
620
+ openCompose({
621
+ mailboxUid: message.mailboxUid,
622
+ to: message.from.address,
623
+ subject: replySubject(message.subject),
624
+ quotedHtml: buildReplyQuote(message),
625
+ signatureContext: "reply_forward",
626
+ suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
627
+ });
628
+ }
629
+
630
+ function handleReplyAll() {
631
+ const cc = message.recipients.filter((r) => r.type !== "bcc").map((r) => r.address);
632
+ openCompose({
633
+ mailboxUid: message.mailboxUid,
634
+ to: message.from.address,
635
+ cc: cc.join(", "),
636
+ subject: replySubject(message.subject),
637
+ quotedHtml: buildReplyQuote(message),
638
+ signatureContext: "reply_forward",
639
+ suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
640
+ });
641
+ }
642
+
643
+ function handleForward() {
644
+ openCompose({
645
+ mailboxUid: message.mailboxUid,
646
+ subject: forwardSubject(message.subject),
647
+ quotedHtml: buildForwardQuote(message),
648
+ signatureContext: "reply_forward",
649
+ });
650
+ }
651
+
652
+ async function handleRecall() {
653
+ // Only ever invoked from the confirmation modal below, which itself only renders once `message`
654
+ // is loaded (the early return above covers the only other state) — the non-null assertion
655
+ // reflects that real invariant, matching this codebase's established pattern for the same class
656
+ // of always-true-in-practice guard.
657
+ setRecalling(true);
658
+ setError(null);
659
+ try {
660
+ const updated = await recallMessage(message.uid);
661
+ setConfirming(false);
662
+ onRecalled?.(updated);
663
+ } catch (err) {
664
+ setError(err instanceof ApiRequestError ? err.message : "Could not recall this message.");
665
+ } finally {
666
+ setRecalling(false);
667
+ }
668
+ }
669
+
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.
674
+ async function handleCancelScheduledSend() {
675
+ setCanceling(true);
676
+ setCancelError(null);
677
+ try {
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);
681
+ onScheduledSendCanceled?.(updated);
682
+ } catch (err) {
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
+ }
690
+ } finally {
691
+ setCanceling(false);
692
+ }
693
+ }
694
+
695
+ // Only ever invoked from the Archive button below, which itself only renders once `message` is
696
+ // loaded and this isn't a Drafts/Outbox message (the button's own guard mirrors
697
+ // `BaseMessageRoute.archive()`'s server-side 400) — same real invariant as `handleRecall` above.
698
+ async function handleArchive() {
699
+ setArchiving(true);
700
+ setArchiveError(null);
701
+ try {
702
+ const updated = await archiveMessage(message.uid);
703
+ void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
704
+ onArchived?.(updated);
705
+ } catch (err) {
706
+ setArchiveError(err instanceof ApiRequestError ? err.message : "Could not archive this message.");
707
+ } finally {
708
+ setArchiving(false);
709
+ }
710
+ }
711
+
712
+ // Only ever invoked from a checkbox in the Labels popover below, which itself only renders once
713
+ // `message` is loaded — auto-saves on every toggle (no separate "Save" step), computing the full
714
+ // new `labelUids` set from the message's current one since `setMessageLabels()` replaces the whole
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.
722
+ async function handleToggleLabel(labelUid: string) {
723
+ setTogglingLabelUid(labelUid);
724
+ setLabelsError(null);
725
+ try {
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 ?? [];
730
+ const next = current.includes(labelUid) ? current.filter((uid) => uid !== labelUid) : [...current, labelUid];
731
+ const updated = await setMessageLabels(base, next);
732
+ latestLabelsMessageRef.current = updated;
733
+ setLabelsMessage(updated);
734
+ onLabelsChanged?.(updated);
735
+ } catch (err) {
736
+ setLabelsError(err instanceof ApiRequestError ? err.message : "Could not update this message's labels.");
737
+ } finally {
738
+ setTogglingLabelUid(null);
739
+ }
740
+ }
741
+
742
+ // Only ever invoked from the classification button below, which itself only renders once `message`
743
+ // is loaded and `isInbox` is true — the non-null assertion reflects the same real invariant as
744
+ // `handleRecall`/`handleCancelScheduledSend` above.
745
+ async function handleClassify(classifyAs: MessageClassification) {
746
+ setClassifying(true);
747
+ setClassifyError(null);
748
+ try {
749
+ const updated = await classifyMessage(message.uid, classifyAs, alwaysForSender);
750
+ onClassified?.(updated);
751
+ } catch (err) {
752
+ setClassifyError(err instanceof ApiRequestError ? err.message : "Could not reclassify this message.");
753
+ } finally {
754
+ setClassifying(false);
755
+ }
756
+ }
757
+
758
+ // Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
759
+ // is loaded — same real invariant as every other handler above.
760
+ async function handleReceipt(type: ReceiptType, action: "approve" | "decline") {
761
+ setReceiptBusy(type);
762
+ setReceiptError(null);
763
+ try {
764
+ const updated = await (action === "approve" ? approveReceipt : declineReceipt)(message.uid, type);
765
+ onReceiptHandled?.(updated);
766
+ } catch (err) {
767
+ setReceiptError(err instanceof ApiRequestError ? err.message : "Could not handle this receipt request.");
768
+ } finally {
769
+ setReceiptBusy(null);
770
+ }
771
+ }
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
+
816
+ return (
817
+ <div className="flex-1 min-w-0 flex flex-col">
818
+ <div className="border-b border-border p-4">
819
+ {backHref && (
820
+ <a href={backHref} className="text-sm text-primary-dark hover:underline block mb-2">
821
+ &larr; Back to messages
822
+ </a>
823
+ )}
824
+ <div className="flex items-start justify-between gap-3">
825
+ <div className="flex items-center gap-2 min-w-0">
826
+ <h1 className="text-lg font-bold tracking-tight truncate">{(protectedSubject ?? message.subject) || "(no subject)"}</h1>
827
+ {security && <SecurityIndicator security={security} />}
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&hellip;
832
+ </span>
833
+ )}
834
+ {isSentItems &&
835
+ (message.recallRequestedAt ? (
836
+ <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
837
+ Recall requested
838
+ </span>
839
+ ) : (
840
+ <Button
841
+ type="button"
842
+ variant="secondary"
843
+ className="!w-auto shrink-0"
844
+ onClick={() => setConfirming(true)}
845
+ >
846
+ Recall this message
847
+ </Button>
848
+ ))}
849
+ {inOutbox && !sendInProgress && message.scheduledSendTime && (
850
+ <div className="flex items-center gap-2 shrink-0">
851
+ <span className="text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt">
852
+ Scheduled for {new Date(message.scheduledSendTime).toLocaleString()}
853
+ </span>
854
+ <Button
855
+ type="button"
856
+ variant="secondary"
857
+ className="!w-auto"
858
+ loading={canceling}
859
+ disabled={canceling}
860
+ onClick={handleCancelScheduledSend}
861
+ >
862
+ Cancel
863
+ </Button>
864
+ </div>
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
+ )}
882
+ </div>
883
+ {inOutbox && message.scheduledSendError && (
884
+ <div className="mt-2">
885
+ <Alert>This message wasn&rsquo;t sent: {message.scheduledSendError}</Alert>
886
+ </div>
887
+ )}
888
+ {cancelError && (
889
+ <div className="mt-2">
890
+ <Alert>{cancelError}</Alert>
891
+ </div>
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&rsquo;t a trusted contact key, so the sender isn&rsquo;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&rsquo;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 (&ldquo;{message.subject}&rdquo;), which may have been changed on the way, e.g. by a
947
+ mailing list.
948
+ </p>
949
+ )}
950
+ {security?.headerTamperDetected && (
951
+ <div className="mt-2">
952
+ <Alert>
953
+ This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or
954
+ encrypted — an intermediary may have altered them after sending. Treat the fields shown above with
955
+ caution.
956
+ </Alert>
957
+ </div>
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&rsquo;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&rsquo;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&rsquo;s content and attachments only. Its Subject, To and Cc
1010
+ weren&rsquo;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&rsquo;t include this mailbox - it may have
1017
+ been forwarded or re-sent to you unchanged, or you were Bcc&rsquo;d.
1018
+ </p>
1019
+ )}
1020
+ <p className="text-sm text-text-muted mt-1">
1021
+ From {senderLabel} &middot; {new Date(message.receivedDate).toLocaleString()}
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&rsquo;s name &ldquo;{senderName}&rdquo; looks like an email address, but this message was
1026
+ sent from <span className="font-medium">{senderAddress}</span>. Don&rsquo;t trust it based on the name.
1027
+ </p>
1028
+ )}
1029
+ <p className="text-sm text-text-muted">
1030
+ To {message.recipients.map((r) => r.displayName || r.address).join(", ")}
1031
+ </p>
1032
+ <div className="flex gap-2 mt-3">
1033
+ <Button type="button" variant="secondary" className="!w-auto" onClick={handleReply}>
1034
+ Reply
1035
+ </Button>
1036
+ <Button type="button" variant="secondary" className="!w-auto" onClick={handleReplyAll}>
1037
+ Reply All
1038
+ </Button>
1039
+ <Button type="button" variant="secondary" className="!w-auto" onClick={handleForward}>
1040
+ Forward
1041
+ </Button>
1042
+ {!inOutbox && message.folderUid !== draftsFolderUid && (
1043
+ <Button
1044
+ type="button"
1045
+ variant="secondary"
1046
+ className="!w-auto"
1047
+ loading={archiving}
1048
+ disabled={archiving}
1049
+ onClick={handleArchive}
1050
+ >
1051
+ Archive
1052
+ </Button>
1053
+ )}
1054
+ {labels && labels.length > 0 && (
1055
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setLabelsOpen(true)}>
1056
+ Labels
1057
+ </Button>
1058
+ )}
1059
+ </div>
1060
+ {archiveError && (
1061
+ <div className="mt-2">
1062
+ <Alert>{archiveError}</Alert>
1063
+ </div>
1064
+ )}
1065
+ {labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (
1066
+ <div className="flex flex-wrap items-center gap-1.5 mt-2">
1067
+ {currentLabelsMessage
1068
+ .labelUids!.map((uid) => labels.find((l) => l.uid === uid))
1069
+ .filter((l): l is Label => !!l)
1070
+ .map((l) => (
1071
+ <span
1072
+ key={l.uid}
1073
+ className="inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt"
1074
+ >
1075
+ <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
1076
+ {l.name}
1077
+ </span>
1078
+ ))}
1079
+ </div>
1080
+ )}
1081
+ {isInbox && (
1082
+ <div className="flex flex-wrap items-center gap-2 mt-2">
1083
+ <Button
1084
+ type="button"
1085
+ variant="text"
1086
+ loading={classifying}
1087
+ disabled={classifying}
1088
+ onClick={() =>
1089
+ handleClassify(message.inferenceClassification === "other" ? "focused" : "other")
1090
+ }
1091
+ >
1092
+ {message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other"}
1093
+ </Button>
1094
+ <label className="flex items-center gap-1.5 text-xs text-text-muted">
1095
+ <input
1096
+ type="checkbox"
1097
+ checked={alwaysForSender}
1098
+ onChange={(e) => setAlwaysForSender(e.target.checked)}
1099
+ />
1100
+ Always for this sender
1101
+ </label>
1102
+ </div>
1103
+ )}
1104
+ {classifyError && (
1105
+ <div className="mt-2">
1106
+ <Alert>{classifyError}</Alert>
1107
+ </div>
1108
+ )}
1109
+ {(["delivery", "read"] as const)
1110
+ .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
1111
+ .map((type) => (
1112
+ <div
1113
+ key={type}
1114
+ className="flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm"
1115
+ >
1116
+ <span>
1117
+ {senderLabel} requested a {type} receipt for this
1118
+ message.
1119
+ </span>
1120
+ <Button
1121
+ type="button"
1122
+ variant="secondary"
1123
+ className="!w-auto"
1124
+ loading={receiptBusy === type}
1125
+ disabled={receiptBusy !== null}
1126
+ onClick={() => handleReceipt(type, "approve")}
1127
+ >
1128
+ Send receipt
1129
+ </Button>
1130
+ <Button
1131
+ type="button"
1132
+ variant="text"
1133
+ disabled={receiptBusy !== null}
1134
+ onClick={() => handleReceipt(type, "decline")}
1135
+ >
1136
+ Decline
1137
+ </Button>
1138
+ </div>
1139
+ ))}
1140
+ {receiptError && (
1141
+ <div className="mt-2">
1142
+ <Alert>{receiptError}</Alert>
1143
+ </div>
1144
+ )}
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&rsquo;t be verified. Open them only if
1148
+ you trust the sender.
1149
+ </p>
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
+ )}
1181
+ </div>
1182
+ {security?.decryptError && (
1183
+ <div className="px-4 pt-2">
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
+ )}
1195
+ </div>
1196
+ )}
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 ? (
1206
+ // A decrypted/verified body never came through the server's own sanitize-html pass (it
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=""`.
1210
+ <iframe
1211
+ key={message.uid}
1212
+ title={message.subject || "Message content"}
1213
+ srcDoc={buildSecureSrcDoc(security.html)}
1214
+ sandbox=""
1215
+ className="flex-1 w-full border-0"
1216
+ />
1217
+ ) : (
1218
+ <iframe
1219
+ key={message.uid}
1220
+ title={message.subject || "Message content"}
1221
+ src={`/api/mail/messages/${encodeURIComponent(message.uid)}/content`}
1222
+ sandbox=""
1223
+ className="flex-1 w-full border-0"
1224
+ />
1225
+ )}
1226
+
1227
+ <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
1228
+ <p className="text-sm text-text-muted mb-4">
1229
+ This asks every original recipient's mail system to delete their copy, but only if it's
1230
+ still unread there — there's no way to guarantee it, and no confirmation once it either
1231
+ succeeds or fails. Recipients who already read the message will keep it.
1232
+ </p>
1233
+ {error && <Alert>{error}</Alert>}
1234
+ <div className="flex gap-3">
1235
+ <Button type="button" loading={recalling} disabled={recalling} onClick={handleRecall} className="!w-auto">
1236
+ Recall message
1237
+ </Button>
1238
+ <Button
1239
+ type="button"
1240
+ variant="secondary"
1241
+ disabled={recalling}
1242
+ onClick={() => setConfirming(false)}
1243
+ className="!w-auto"
1244
+ >
1245
+ Cancel
1246
+ </Button>
1247
+ </div>
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>
1294
+ <Modal open={labelsOpen} onClose={() => setLabelsOpen(false)} title="Labels">
1295
+ {labelsError && <Alert>{labelsError}</Alert>}
1296
+ <ul className="flex flex-col gap-1">
1297
+ {(labels ?? []).map((l) => {
1298
+ const checked = currentLabelsMessage.labelUids?.includes(l.uid) ?? false;
1299
+ return (
1300
+ <li key={l.uid}>
1301
+ <label className="flex items-center gap-2 py-1.5 px-1 text-sm rounded-sm hover:bg-surface-alt">
1302
+ <input
1303
+ type="checkbox"
1304
+ checked={checked}
1305
+ disabled={togglingLabelUid !== null}
1306
+ onChange={() => handleToggleLabel(l.uid)}
1307
+ />
1308
+ <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
1309
+ {l.name}
1310
+ </label>
1311
+ </li>
1312
+ );
1313
+ })}
1314
+ </ul>
1315
+ </Modal>
1316
+ </div>
1317
+ );
1318
+ }