@rapidmx/web-client 0.8.0 → 0.10.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 (247) hide show
  1. package/README.md +192 -88
  2. package/apps/shared/auth/accountUrl.ts +9 -0
  3. package/apps/shared/auth/adminAccess.ts +99 -0
  4. package/apps/shared/components/admin/elevation.ts +61 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
  6. package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
  7. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +87 -24
  8. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  9. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  10. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  11. package/apps/shared/components/layout/AppShell.tsx +411 -288
  12. package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
  13. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  14. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -126
  15. package/apps/shared/components/layout/UserMenu.tsx +303 -150
  16. package/apps/shared/components/mail/ConversationList.tsx +269 -253
  17. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  18. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  19. package/apps/shared/components/mail/MailAddress.tsx +88 -0
  20. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1383
  22. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  23. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  24. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  25. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1700
  26. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  27. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  28. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
  29. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  31. package/apps/shared/components/mail/layout/MailShell.tsx +476 -445
  32. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  34. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  35. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  36. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  37. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  38. package/apps/shared/keyboard/dispatch.ts +124 -0
  39. package/apps/shared/keyboard/format.ts +89 -0
  40. package/apps/shared/keyboard/keymap.ts +114 -0
  41. package/apps/shared/keyboard/match.ts +44 -0
  42. package/apps/shared/keyboard/parse.ts +136 -0
  43. package/apps/shared/keyboard/platform.ts +34 -0
  44. package/apps/shared/keyboard/registry.ts +65 -0
  45. package/apps/shared/keyboard/targets.ts +79 -0
  46. package/apps/shared/keyboard/useShortcut.ts +50 -0
  47. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  48. package/apps/shared/mail/folderCounts.ts +302 -0
  49. package/apps/shared/mail/listSnapshots.ts +87 -0
  50. package/apps/shared/mail/mergeFirstPage.ts +47 -0
  51. package/apps/shared/mail/messageReadState.ts +85 -0
  52. package/apps/shared/mail/newMailNotifications.ts +183 -0
  53. package/apps/shared/mail/useMailConnection.ts +150 -0
  54. package/apps/shared/mail/useMailLiveUpdates.ts +232 -0
  55. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  56. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  57. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  58. package/apps/shared/navigation/AppRouter.tsx +300 -0
  59. package/apps/shared/navigation/appHrefs.ts +23 -0
  60. package/apps/shared/navigation/frameContext.tsx +35 -0
  61. package/apps/shared/navigation/idle.ts +45 -0
  62. package/apps/shared/navigation/routerContext.tsx +83 -0
  63. package/apps/shared/navigation/routes.ts +72 -0
  64. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  65. package/apps/shared/styles/app.css +28 -10
  66. package/apps/www/_routedPage.tsx +24 -0
  67. package/apps/www/_routes.ts +35 -0
  68. package/apps/www/calendar/index.tsx +55 -19
  69. package/apps/www/contacts/[uid].tsx +112 -107
  70. package/apps/www/contacts/index.tsx +584 -567
  71. package/apps/www/index.tsx +2280 -1854
  72. package/apps/www/messages/[uid].tsx +106 -101
  73. package/apps/www/settings/auto-reply/index.tsx +4 -1
  74. package/apps/www/settings/encryption/index.tsx +1252 -1249
  75. package/apps/www/settings/filters/[uid].tsx +4 -1
  76. package/apps/www/settings/filters/index.tsx +102 -99
  77. package/apps/www/settings/filters/new/index.tsx +138 -133
  78. package/apps/www/settings/labels/index.tsx +204 -201
  79. package/apps/www/settings/privacy/index.tsx +4 -1
  80. package/apps/www/settings/read-receipts/index.tsx +4 -1
  81. package/apps/www/settings/sharing/index.tsx +277 -274
  82. package/apps/www/settings/signatures/[uid].tsx +170 -167
  83. package/apps/www/settings/signatures/index.tsx +88 -85
  84. package/apps/www/settings/signatures/new/index.tsx +134 -129
  85. package/apps/www/tasks/index.tsx +18 -2
  86. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  87. package/dist/apps/shared/auth/accountUrl.js +8 -0
  88. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  89. package/dist/apps/shared/auth/adminAccess.js +89 -0
  90. package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
  91. package/dist/apps/shared/components/admin/elevation.js +56 -0
  92. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
  93. package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
  94. package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
  95. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
  96. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  97. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  98. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  99. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  100. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  101. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  102. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  103. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  104. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
  105. package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
  106. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  107. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
  108. package/dist/apps/shared/components/layout/MailboxProvisioning.js +45 -8
  109. package/dist/apps/shared/components/layout/UserMenu.d.ts +28 -9
  110. package/dist/apps/shared/components/layout/UserMenu.js +87 -14
  111. package/dist/apps/shared/components/mail/ConversationList.js +9 -13
  112. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  113. package/dist/apps/shared/components/mail/ConversationThreadPane.js +53 -28
  114. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  115. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  116. package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
  117. package/dist/apps/shared/components/mail/MailAddress.js +39 -0
  118. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  119. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  120. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  121. package/dist/apps/shared/components/mail/MessageDetailPane.js +87 -36
  122. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  123. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  124. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  125. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  126. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  127. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  128. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +122 -27
  129. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  130. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  131. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  132. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  133. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
  134. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
  135. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  136. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  137. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  138. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  139. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +19 -4
  140. package/dist/apps/shared/components/mail/layout/MailShell.js +85 -85
  141. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  142. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  143. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  144. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  145. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  146. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  147. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  148. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  149. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  150. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  151. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  152. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  153. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  154. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  155. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  156. package/dist/apps/shared/keyboard/format.js +74 -0
  157. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  158. package/dist/apps/shared/keyboard/keymap.js +84 -0
  159. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  160. package/dist/apps/shared/keyboard/match.js +31 -0
  161. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  162. package/dist/apps/shared/keyboard/parse.js +107 -0
  163. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  164. package/dist/apps/shared/keyboard/platform.js +22 -0
  165. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  166. package/dist/apps/shared/keyboard/registry.js +33 -0
  167. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  168. package/dist/apps/shared/keyboard/targets.js +67 -0
  169. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  170. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  171. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  172. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  173. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  174. package/dist/apps/shared/mail/folderCounts.js +212 -0
  175. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  176. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  177. package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
  178. package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
  179. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  180. package/dist/apps/shared/mail/messageReadState.js +63 -0
  181. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  182. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  183. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  184. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  185. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +55 -0
  186. package/dist/apps/shared/mail/useMailLiveUpdates.js +182 -0
  187. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  188. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  189. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  190. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  191. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  192. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  193. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  194. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  195. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  196. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  197. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  198. package/dist/apps/shared/navigation/frameContext.js +24 -0
  199. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  200. package/dist/apps/shared/navigation/idle.js +43 -0
  201. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  202. package/dist/apps/shared/navigation/routerContext.js +56 -0
  203. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  204. package/dist/apps/shared/navigation/routes.js +37 -0
  205. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  206. package/dist/apps/shared/styles/app.css +28 -10
  207. package/dist/apps/www/_routedPage.d.ts +12 -0
  208. package/dist/apps/www/_routedPage.js +19 -0
  209. package/dist/apps/www/_routes.d.ts +11 -0
  210. package/dist/apps/www/_routes.js +29 -0
  211. package/dist/apps/www/calendar/index.d.ts +2 -2
  212. package/dist/apps/www/calendar/index.js +39 -8
  213. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  214. package/dist/apps/www/contacts/[uid].js +6 -2
  215. package/dist/apps/www/contacts/index.d.ts +2 -2
  216. package/dist/apps/www/contacts/index.js +17 -4
  217. package/dist/apps/www/index.d.ts +2 -2
  218. package/dist/apps/www/index.js +361 -34
  219. package/dist/apps/www/messages/[uid].d.ts +3 -7
  220. package/dist/apps/www/messages/[uid].js +7 -4
  221. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  222. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  223. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  224. package/dist/apps/www/settings/encryption/index.js +3 -1
  225. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  226. package/dist/apps/www/settings/filters/[uid].js +3 -1
  227. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  228. package/dist/apps/www/settings/filters/index.js +3 -1
  229. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  230. package/dist/apps/www/settings/filters/new/index.js +6 -2
  231. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  232. package/dist/apps/www/settings/labels/index.js +3 -1
  233. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  234. package/dist/apps/www/settings/privacy/index.js +3 -1
  235. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  236. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  237. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  238. package/dist/apps/www/settings/sharing/index.js +3 -1
  239. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  240. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  241. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  242. package/dist/apps/www/settings/signatures/index.js +3 -1
  243. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  244. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  245. package/dist/apps/www/tasks/index.d.ts +2 -2
  246. package/dist/apps/www/tasks/index.js +15 -3
  247. package/package.json +2 -2
@@ -1,1383 +1,1458 @@
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 {
7
- HiOutlineArchiveBox,
8
- HiOutlineArrowUturnLeft,
9
- HiOutlineArrowUturnRight,
10
- HiOutlineCheck,
11
- HiOutlineExclamationTriangle,
12
- HiOutlineFolderArrowDown,
13
- HiOutlineLockClosed,
14
- } from "react-icons/hi2";
15
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
16
- import {
17
- Attachment,
18
- Folder,
19
- Message,
20
- ReceiptType,
21
- Recipient,
22
- approveReceipt,
23
- archiveMessage,
24
- attachmentContentUrl,
25
- cancelScheduledSend,
26
- declineReceipt,
27
- getMailbox,
28
- getMessage,
29
- getMessageRawContent,
30
- moveMessage,
31
- recallMessage,
32
- setMessageLabels,
33
- } from "@rapidmx/react-shared/mail/mailApi.js";
34
- import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
35
- import {
36
- buildForwardQuote,
37
- buildReplyQuote,
38
- buildReplyRecipients,
39
- buildReplyThreading,
40
- forwardSubject,
41
- replySubject,
42
- } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
43
- import { sanitizeMessageBodyHtml } from "@rapidmx/react-shared/mail/messageBodySanitizer.js";
44
- import MoveToFolderDialog from "./MoveToFolderDialog.js";
45
- import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
46
- import {
47
- MessageSecurityResult,
48
- SignatureFailureReason,
49
- evaluateMessageSecurity,
50
- evaluateMessageSecurityWithSeal,
51
- } from "@rapidmx/react-shared/crypto/messageSecurity.js";
52
- import { extractAddresses, type MimeAttachment } from "@rapidmx/react-shared/crypto/mime.js";
53
- import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
54
- import { isLikelyMailingList } from "@rapidmx/react-shared/crypto/composeSecurity.js";
55
- import { SenderKeyState, clearPinnedSignerCache, getPinnedSignerFingerprints, getSignerKeyState } from "./pinnedSigners.js";
56
- import { currentVerificationSeal, getVaultGeneration, sendVerificationSeal } from "./verificationSeals.js";
57
- import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
58
- import KeyChangeReview from "../contacts/KeyChangeReview.js";
59
- import { KEY_CHANGE_STALE_MESSAGE, sameFingerprint } from "../contacts/contactKeys.js";
60
- import { useCompose } from "./compose/ComposeContext.js";
61
- import { loadOriginalMessage } from "./compose/quotedBody.js";
62
- import { formatRecipient } from "./compose/recipients.js";
63
- import { useMailShell } from "./layout/MailShell.js";
64
- import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
65
- import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
66
- import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
67
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
68
- import LabelMenuButton from "./labelMenu.js";
69
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
70
-
71
- /** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
72
- * plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
73
- * read as a muted variant of "verified" (a failed signature is a stronger negative than no signature at
74
- * all), and "Unprotected" MUST NOT read as an error - the class pairs below are chosen so those two
75
- * never share styling with each other or with the verified states. */
76
- const SECURITY_INDICATOR: Record<MessageSecurityResult["state"], { label: string; className: string }> = {
77
- unprotected: { label: "Unprotected", className: "bg-surface-alt text-text-muted" },
78
- encrypted: { label: "Encrypted", className: "bg-primary/10 text-primary-dark" },
79
- signed_verified: { label: "Signed & verified", className: "bg-success/10 text-success" },
80
- encrypted_verified: { label: "Encrypted & verified", className: "bg-success/10 text-success" },
81
- // A valid signature from a certificate the reader hasn't pinned - anyone can mint a certificate naming the
82
- // sender, so this must never look like the verified states.
83
- signed_unverified_signer: { label: "Signed - signer not verified", className: "bg-warning/15 text-text" },
84
- encrypted_unverified_signer: { label: "Encrypted - signer not verified", className: "bg-warning/15 text-text" },
85
- signature_failed: { label: "Signature failed", className: "bg-danger-bg text-danger" },
86
- // Verified when first opened (a verification seal), but not live: muted, never the green verified pill. A signer key
87
- // later reported compromised switches it to `LATER_COMPROMISED_INDICATOR_CLASS`.
88
- verified_at_first_open: { label: "Verified when first opened", className: "bg-surface-alt text-text-muted" },
89
- };
90
-
91
- /** The amber `verified_at_first_open` badge for a signer key since reported compromised. */
92
- const LATER_COMPROMISED_INDICATOR_CLASS = "bg-warning/15 text-text";
93
-
94
- /** Whether a result is (or, for `verified_at_first_open`, was live) a `signer_key_changed` failure. */
95
- function signerKeyChanged(result: MessageSecurityResult): boolean {
96
- return result.signatureFailureReason === "signer_key_changed" || result.liveSignatureFailureReason === "signer_key_changed";
97
- }
98
-
99
- /** The detail line under a `verified_at_first_open` badge. */
100
- export function verifiedAtFirstOpenMessage(result: MessageSecurityResult): string {
101
- const date = new Date(result.verifiedAt!).toLocaleDateString();
102
- if (result.laterCompromised) {
103
- return `This signature was verified on ${date}, but the sender's key was later reported compromised; treat this message with caution.`;
104
- }
105
- if (result.liveSignatureFailureReason === "signer_key_changed") {
106
- return `This signature was verified on ${date}. The sender has since started signing with a different key.`;
107
- }
108
- 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.`;
109
- }
110
-
111
- const VERIFIED_STATES = new Set<MessageSecurityResult["state"]>(["signed_verified", "encrypted_verified"]);
112
- const UNVERIFIED_SIGNER_STATES = new Set<MessageSecurityResult["state"]>(["signed_unverified_signer", "encrypted_unverified_signer"]);
113
-
114
- /** An `@`, or a look-alike a reader would take for one (fullwidth, small) - restapi's own `AT_SIGN_LIKE`. */
115
- const AT_SIGN_LIKE = /[@\uFF20\uFE6B]/;
116
-
117
- export interface SenderNameCheck {
118
- /** The display name contains an `@` or a look-alike - it reads as (part of) an address. */
119
- looksLikeAddress: boolean;
120
- /** It shows an address-shaped token that isn't `actualAddress` - e.g. `"ceo@corp.com" <x@corp-pay.com>`. */
121
- misleading: boolean;
122
- }
123
-
124
- /** Whether a sender's display name poses as an address, and whether that address differs from the one actually sent
125
- * from. Compatibility forms are folded first (NFKC turns the fullwidth and small @ into `@`), so a look-alike can't
126
- * hide the token. */
127
- export function checkSenderName(displayName: string | undefined, actualAddress: string): SenderNameCheck {
128
- if (!displayName || !AT_SIGN_LIKE.test(displayName)) {
129
- return { looksLikeAddress: false, misleading: false };
130
- }
131
- const shown = /[^\s<>"'(),;:]+@[^\s<>"'(),;:]+/.exec(displayName.normalize("NFKC"))?.[0];
132
- return { looksLikeAddress: true, misleading: shown !== undefined && shown.toLowerCase() !== actualAddress.toLowerCase() };
133
- }
134
-
135
- export const TRUST_SIGNER_CONFLICT_MESSAGE =
136
- "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.";
137
- export const TRUST_SIGNER_INVALID_MESSAGE = "This certificate can't be trusted for this sender.";
138
- export const TRUST_SIGNER_FORBIDDEN_MESSAGE = "You don't have permission to trust signers for this mailbox.";
139
- export const TRUST_SIGNER_GENERIC_MESSAGE = "Couldn't trust this signer. Try again.";
140
- export const KEPT_CURRENT_SIGNING_KEY_MESSAGE = "You kept the current signing key for this sender. This message stays unverified.";
141
-
142
- /** The error text for a failed `trustSigner()`. */
143
- export function trustSignerErrorMessage(err: unknown): string {
144
- if (err instanceof SignerKeyConflictError) {
145
- return TRUST_SIGNER_CONFLICT_MESSAGE;
146
- }
147
- if (err instanceof ApiRequestError && err.status === 400) {
148
- return TRUST_SIGNER_INVALID_MESSAGE;
149
- }
150
- if (err instanceof ApiRequestError && err.status === 403) {
151
- return TRUST_SIGNER_FORBIDDEN_MESSAGE;
152
- }
153
- return TRUST_SIGNER_GENERIC_MESSAGE;
154
- }
155
-
156
- /** Groups a fingerprint into 4-character blocks (`AB12 CD34 ...`), ignoring any separators it came with, so it can be
157
- * read out and compared with the sender over another channel. */
158
- export function formatFingerprint(fingerprint: string): string {
159
- const compact = fingerprint.replace(/[\s:]/g, "").toUpperCase();
160
- return compact.match(/.{1,4}/g)?.join(" ") ?? compact;
161
- }
162
-
163
- /** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
164
- * opaque download (`application/octet-stream`), never rendered in this origin. */
165
- function downloadMimeAttachment(attachment: MimeAttachment): void {
166
- // `decode()` is `undefined` only for invalid base64 - an empty file is the honest result then.
167
- const bytes = attachment.decode() ?? new Uint8Array(0);
168
- const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: "application/octet-stream" }));
169
- const a = document.createElement("a");
170
- a.href = url;
171
- a.download = attachment.filename ?? "attachment";
172
- document.body.appendChild(a);
173
- a.click();
174
- a.remove();
175
- // Some browsers are still reading the blob after `click()` returns.
176
- setTimeout(() => URL.revokeObjectURL(url), 60_000);
177
- }
178
-
179
- /**
180
- * One of the reading pane's own actions - Reply, Reply All, Forward, Archive, Move to Other - as an icon
181
- * button. The action's name is its `aria-label` *and* its tooltip, so it has the same accessible name a
182
- * labelled button had, and is never drawn beside the icon at any width: the row is icon-only everywhere,
183
- * the way Outlook's own reading-pane command bar is, so it reads the same in the ~396px pane beside the
184
- * message list and in a maximised window rather than changing shape at a breakpoint.
185
- *
186
- * A plain `<button>` rather than react-shared's `Button`, whose padding and minimum width are sized for a
187
- * text label. Ordinary DOM order, so the keyboard reaches these in the order they are read.
188
- */
189
- function IconAction({
190
- icon,
191
- label,
192
- onClick,
193
- disabled,
194
- busy,
195
- }: {
196
- icon: React.ReactNode;
197
- label: string;
198
- onClick: () => void;
199
- disabled?: boolean;
200
- busy?: boolean;
201
- }) {
202
- return (
203
- <button
204
- type="button"
205
- aria-label={label}
206
- title={label}
207
- aria-busy={busy || undefined}
208
- disabled={disabled}
209
- onClick={onClick}
210
- className="inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent"
211
- >
212
- {icon}
213
- </button>
214
- );
215
- }
216
-
217
- function SecurityIndicator({ security }: { security: MessageSecurityResult }) {
218
- const { label, className } = SECURITY_INDICATOR[security.state];
219
- if (security.state !== "verified_at_first_open") {
220
- return <span className={`text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`}>{label}</span>;
221
- }
222
- return (
223
- <span
224
- className={`inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${
225
- security.laterCompromised ? LATER_COMPROMISED_INDICATOR_CLASS : className
226
- }`}
227
- >
228
- {security.laterCompromised ? (
229
- <HiOutlineExclamationTriangle size={12} aria-hidden="true" />
230
- ) : (
231
- <HiOutlineCheck size={12} aria-hidden="true" />
232
- )}
233
- {label}
234
- </span>
235
- );
236
- }
237
-
238
- /** User-facing explanation for each `signatureFailureReason` - shown alongside the "Signature failed"
239
- * badge so an unverifiable (e.g. foreign/unparseable) signed message reads as "not verified, and here's
240
- * why" rather than as a bare error. The body itself stays visible either way. */
241
- const SIGNATURE_FAILURE_MESSAGE: Record<SignatureFailureReason, string> = {
242
- invalid_signature:
243
- "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.",
244
- untrusted_signer: "This message was signed with a certificate that doesn't match the sender's known key. Treat it as unverified.",
245
- // Never shown: the dedicated "signing key changed" notice replaces it. Present so every reason has copy.
246
- signer_key_changed: "This message was signed with a different key than the one you trust for this sender. Treat it as unverified.",
247
- signer_identity_mismatch:
248
- "This message's signing certificate doesn't belong to the sender shown in From. Treat it as unverified.",
249
- header_mismatch:
250
- "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.",
251
- };
252
- const GENERIC_SIGNATURE_FAILURE_MESSAGE = "This message's digital signature couldn't be verified. Treat it as unverified.";
253
-
254
- /** Fallback when the raw MIME for an encrypted message can't even be fetched/evaluated - an encrypted
255
- * message must never be mislabeled "Unprotected" just because loading failed. */
256
- const ENCRYPTED_LOAD_ERROR = "Couldn't load this message's encrypted content.";
257
-
258
- /** Content Security Policy prepended to every client-rendered (decrypted/verified) body's `srcDoc` - the
259
- * backstop behind the sanitizer below: nothing in the document may load any remote resource at all, only
260
- * inline styles and `data:`/`cid:` images. */
261
- const BODY_CSP_META =
262
- "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; img-src data: cid:; style-src 'unsafe-inline'\">";
263
-
264
- /** Sanitizes a client-rendered body for `srcDoc` (DOMPurify plus every remote resource reference stripped - see
265
- * react-shared's `messageBodySanitizer.ts`), with the CSP meta as its very first element. */
266
- export function buildSecureSrcDoc(html: string): string {
267
- return BODY_CSP_META + sanitizeMessageBodyHtml(html);
268
- }
269
-
270
- export interface MessageDetailPaneProps {
271
- message: Message | null;
272
- attachments: Attachment[];
273
- /** Present only on the mobile detail route — renders a "back to messages" link above the header. Absent
274
- * on the desktop reading pane, which never navigates away (selecting a different message just swaps
275
- * `message` in place). */
276
- backHref?: string;
277
- /** Whether `message` currently lives in Sent Items — the only folder recall is offered from, matching
278
- * Outlook's own restriction (and `BaseMessageRoute.recall()`'s own server-side check). Each caller
279
- * computes this from its own already-loaded folder list rather than this component fetching folders
280
- * itself. */
281
- isSentItems?: boolean;
282
- /** Called with the server's updated copy (carrying `recallRequestedAt`) after a successful recall, so
283
- * the caller can patch its own in-memory message/list state — mirrors `mailDetailHooks.ts`'s
284
- * `useMarkMessageRead`'s identical `onUpdated` callback. */
285
- onRecalled?: (updated: Message) => void;
286
- /** Whether `message` currently lives in Outbox — the only folder a scheduled send can be canceled
287
- * from. Each caller computes this the same way it computes `isSentItems`. */
288
- isOutbox?: boolean;
289
- /** The mailbox's Drafts folder uid — required when `isOutbox` is `true`, since canceling a
290
- * scheduled send moves the message back into Drafts (see `cancelScheduledSend()`'s own doc comment
291
- * on why clearing `scheduledSendTime` alone doesn't do that). */
292
- draftsFolderUid?: string;
293
- /** Called with the server's updated copy (now back in Drafts, `scheduledSendTime` cleared) after
294
- * successfully canceling a scheduled send. */
295
- onScheduledSendCanceled?: (updated: Message) => void;
296
- /** Every folder of this message's own mailbox, for the Move to prompt's destination list. Absent or
297
- * empty simply hides the control — there is nowhere to move to. Each caller already resolves this to
298
- * work out `isSentItems`/`isOutbox`/`draftsFolderUid`, so nothing new is fetched for it. */
299
- folders?: Folder[];
300
- /** Called with the server's updated copy (now in the chosen folder) after a successful Move to. A move
301
- * takes the message out of whichever folder is being listed, so callers remove it from their list
302
- * rather than patching it — unlike `onClassified`, which this replaces. */
303
- onMoved?: (updated: Message) => void;
304
- /** A folder created from the Move to prompt, so the caller's own list and the folder sidebar pick it up
305
- * without a page load. */
306
- onFolderCreated?: (folder: Folder) => void;
307
- /** Called with the server's updated copy after approving/declining a pending delivery/read receipt —
308
- * mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`):
309
- * `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are only ever
310
- * `true` on a real delivered copy, so the banner below is self-gating. */
311
- onReceiptHandled?: (updated: Message) => void;
312
- /** Called with the server's updated copy (now filed under the mailbox's Archive folder) after a
313
- * successful archive — mirrors `onRecalled`'s identical shape. Archiving itself is offered for any
314
- * message except one currently in Drafts or Outbox (mirrors `BaseMessageRoute.archive()`'s own
315
- * server-side 400 guard); Drafts is detected via `draftsFolderUid` (already passed by every caller
316
- * for the Outbox-cancel flow) rather than a new prop. */
317
- onArchived?: (updated: Message) => void;
318
- /** Every label defined in this message's mailbox, for the label-assignment popover below — each
319
- * caller fetches its own mailbox's labels the same way it already resolves `draftsFolderUid`
320
- * (`listLabels()`). Absent/empty simply hides the Labels control - there's nothing to assign. */
321
- labels?: Label[];
322
- /** Called with the server's updated copy (carrying the new `labelUids`) after successfully toggling
323
- * a label — always patches in place, never removes from a caller's list (unlike `onArchived`):
324
- * changing labels never moves a message between folders. */
325
- onLabelsChanged?: (updated: Message) => void;
326
- /** Handed a label created from the Labels menu, for the caller to add to `labels`. Without it the menu
327
- * offers no "New label" row, only the link to where labels are managed. */
328
- onLabelCreated?: (label: Label) => void;
329
- /** Rendered as one message of a thread (`ConversationThreadPane`) rather than as the pane itself: the
330
- * subject becomes a heading under the thread's own, since a document has one `h1` and the thread's is
331
- * the conversation. Everything else - the badges, the actions, the body - is identical. */
332
- inThread?: boolean;
333
- }
334
-
335
- function formatBytes(bytes: number): string {
336
- if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
337
- if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
338
- return `${bytes} B`;
339
- }
340
-
341
- /**
342
- * A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
343
- * Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
344
- * and the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
345
- * a message row) — see each call site for how `message`/`attachments`/`isSentItems` are sourced. The
346
- * desktop pane shows whichever message the list opened, including one opened from a conversation row.
347
- */
348
- export default function MessageDetailPane(props: MessageDetailPaneProps) {
349
- if (!props.message) {
350
- return <p className="p-8 text-sm text-text-muted">Select a message to read it.</p>;
351
- }
352
- // Keyed by uid so every piece of per-message state below (security result, open modals, in-flight
353
- // flags, errors) resets cleanly when a caller swaps `message` in place rather than remounting.
354
- return <MessageDetailContent key={props.message.uid} {...props} message={props.message} />;
355
- }
356
-
357
- /** Whether a message's raw MIME has to be fetched to evaluate its security state. `encrypted` is the
358
- * server's own classification; there is no server-side "signed" flag, but every S/MIME-signed shape
359
- * (detached `multipart/signed`'s `smime.p7s`, opaque-signed `smime.p7m`) is stored with its CMS part
360
- * surfaced as an attachment - so a message with neither flag can't carry a signature to verify, and
361
- * fetching its full raw source (attachments included) would be pure waste. */
362
- function needsRawSecurityEvaluation(message: Message): boolean {
363
- return !!message.encrypted || message.hasAttachments;
364
- }
365
-
366
- function MessageDetailContent({
367
- message: messageProp,
368
- attachments,
369
- backHref,
370
- isSentItems,
371
- onRecalled,
372
- isOutbox,
373
- draftsFolderUid,
374
- onScheduledSendCanceled,
375
- folders,
376
- onMoved,
377
- onFolderCreated,
378
- onReceiptHandled,
379
- onArchived,
380
- labels,
381
- onLabelsChanged,
382
- onLabelCreated,
383
- inThread,
384
- }: MessageDetailPaneProps & { message: Message }) {
385
- // A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
386
- // `reloadMessage()`. A prop copy newer by `version` wins; an equal-version reload is kept, since claiming a send
387
- // lease need not bump `version`.
388
- const [reloaded, setReloaded] = useState<Message | null>(null);
389
- const message = reloaded && reloaded.version >= messageProp.version ? reloaded : messageProp;
390
- // A reload that finds the message already moved on (sent, or moved to Drafts elsewhere) leaves Outbox.
391
- const inOutbox = !!isOutbox && message.folderUid === messageProp.folderUid;
392
- const { openCompose } = useCompose();
393
- const [confirming, setConfirming] = useState(false);
394
- const [recalling, setRecalling] = useState(false);
395
- const [canceling, setCanceling] = useState(false);
396
- const [archiving, setArchiving] = useState(false);
397
- const [archiveError, setArchiveError] = useState<string | null>(null);
398
- const [savingLabels, setSavingLabels] = useState(false);
399
- const [labelsError, setLabelsError] = useState<string | null>(null);
400
- const [error, setError] = useState<string | null>(null);
401
- // Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
402
- // pane rather than inside a confirmation modal — the two flows never need to share one message.
403
- const [cancelError, setCancelError] = useState<string | null>(null);
404
- const [movePrompt, setMovePrompt] = useState(false);
405
- // Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
406
- // `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
407
- // wouldn't distinguish which row's buttons should show a loading state.
408
- const [receiptBusy, setReceiptBusy] = useState<ReceiptType | null>(null);
409
- const [receiptError, setReceiptError] = useState<string | null>(null);
410
- const [security, setSecurity] = useState<MessageSecurityResult | null>(null);
411
- // Whether the pinned-signer lookup for the sender *succeeded* and found no signing keys - the only case "Trust this
412
- // signer" is offered in. A failed lookup could be hiding an existing pin, and trusting never replaces one.
413
- const [senderUnpinned, setSenderUnpinned] = useState(false);
414
- const [trustConfirmOpen, setTrustConfirmOpen] = useState(false);
415
- const [trusting, setTrusting] = useState(false);
416
- const [trustError, setTrustError] = useState<string | null>(null);
417
- // The sender's stored signing-key state, loaded for a `signer_key_changed` result (the comparison) and for an
418
- // unverified signer with no pins (a recorded conflict sends the reader to the contact instead of offering trust).
419
- // `undefined` when not needed or it couldn't be loaded.
420
- const [senderKeyState, setSenderKeyState] = useState<SenderKeyState | undefined>(undefined);
421
- // Whether the reader may update this mailbox (resolving a key change needs it) - `undefined` when unknown.
422
- const [canUpdateMailbox, setCanUpdateMailbox] = useState<boolean | undefined>(undefined);
423
- // "Keys changed while you were looking" after a 409, or the outcome of keeping the current key. Survives the
424
- // re-evaluation that follows.
425
- const [keyChangeNotice, setKeyChangeNotice] = useState<string | null>(null);
426
- // "Now", for deciding whether a scheduled send's lease is still live - advanced when the lease runs out.
427
- const [nowMs, setNowMs] = useState(() => Date.now());
428
- const { mailboxes } = useMailShell();
429
- const { requestUnlock } = useUnlockPrompt();
430
- // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
431
- // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
432
- // state; see keySession.ts's own doc comment).
433
- const [unlockRefresh, setUnlockRefresh] = useState(0);
434
-
435
- // The newest copy of this message known to the Labels popover - normally just the `message` prop,
436
- // but a successful label toggle's server response is held here too, so a follow-up toggle always
437
- // builds on the latest `labelUids`/`version` even before (or without) the caller patching its own
438
- // state via `onLabelsChanged`. A prop copy at least as new (by `version`) always wins.
439
- const [labelsMessage, setLabelsMessage] = useState<Message>(message);
440
- const currentLabelsMessage = message.version >= labelsMessage.version ? message : labelsMessage;
441
- const latestLabelsMessageRef = useRef<Message>(message);
442
- const rawEvaluationNeeded = needsRawSecurityEvaluation(message);
443
- // Every address the viewing mailbox receives at (primary first), as one string so the effect below only
444
- // re-runs when they actually change.
445
- const readerMailbox = mailboxes.find((mb) => mb.uid === message.mailboxUid);
446
- const readerAddressesKey = readerMailbox ? [readerMailbox.primarySmtpAddress, ...(readerMailbox.aliasAddresses ?? [])].join(" ") : "";
447
-
448
- // Evaluates a message's security state from its raw MIME - only for a message that can actually be
449
- // encrypted or signed (see `needsRawSecurityEvaluation()`); anything else is "Unprotected" without a
450
- // fetch. A detached `multipart/signed` message needs its raw MIME read (a sanitized HTML body never
451
- // carries the signature part) to tell "Signed & verified" apart from plain "Unprotected". A
452
- // fetch/parse failure degrades to "Unprotected" (or, for a server-flagged encrypted message, to
453
- // "Encrypted" with an explanation) rather than hiding the message.
454
- //
455
- // `getMessageRawContent()` takes no `AbortSignal`, so a superseded fetch can't be aborted - its
456
- // result is ignored via `cancelled` instead (and never handed to `evaluateMessageSecurity()`).
457
- useEffect(() => {
458
- if (!rawEvaluationNeeded) {
459
- setSecurity({ state: "unprotected" });
460
- return;
461
- }
462
- let cancelled = false;
463
- setSecurity(null);
464
- setSenderUnpinned(false);
465
- setSenderKeyState(undefined);
466
- // The sender's trusted signing keys: what key discovery pinned on this mailbox's contacts, plus this
467
- // mailbox's own signing keys when it sent the message itself. A failed lookup means no pins, which can only
468
- // ever make a signature "signer not verified", never verified.
469
- const senderAddress = message.from.address;
470
- const pinsPromise = getPinnedSignerFingerprints(message.mailboxUid, senderAddress).then(
471
- (fingerprints) => ({ loaded: true, fingerprints }),
472
- () => ({ loaded: false, fingerprints: [] as string[] }),
473
- );
474
- // Verification seals (see `verificationSeals.ts`) need unlocked keys and the vault's current master key
475
- // generation; without either the message is evaluated exactly as before, with no seal. The sender's key records
476
- // are loaded alongside, so a seal can say when the signer key was later revoked as compromised.
477
- let keyStatePromise: Promise<SenderKeyState | undefined> | undefined;
478
- const loadKeyState = () => (keyStatePromise ??= getSignerKeyState(message.mailboxUid, senderAddress).catch(() => undefined));
479
- const sealContextPromise = getUnlockedKeys(message.mailboxUid)
480
- ? getVaultGeneration(message.mailboxUid).then(async (generation) =>
481
- generation === undefined ? undefined : { generation, keyState: await loadKeyState() },
482
- )
483
- : undefined;
484
- getMessageRawContent(message.uid)
485
- .then(async (rawMime) => {
486
- if (cancelled) {
487
- return;
488
- }
489
- const [primaryAddress, ...aliasAddresses] = readerAddressesKey ? readerAddressesKey.split(" ") : [];
490
- const ownAddresses = readerAddressesKey.toLowerCase().split(" ");
491
- const ownKeys = ownAddresses.includes(senderAddress.toLowerCase()) ? (readerMailbox!.keys ?? []) : [];
492
- const ownPins = signingKeyFingerprints(ownKeys);
493
- const [contactPins, sealContext] = await Promise.all([pinsPromise, sealContextPromise]);
494
- const pins = [...new Set([...contactPins.fingerprints, ...ownPins])];
495
- const pinned = pins.length > 0 ? pins : undefined;
496
- // The raw content is a byte string - only ever handed to evaluateMessageSecurity(), never shown. Keys
497
- // are read after the pin lookup's await, so a lock that happened meanwhile is honored.
498
- const unlocked = getUnlockedKeys(message.mailboxUid);
499
- let result: MessageSecurityResult;
500
- if (sealContext && unlocked) {
501
- const signerKeys = [
502
- ...(sealContext.keyState?.pinned ?? []),
503
- ...(sealContext.keyState?.previous ?? []),
504
- ...ownKeys,
505
- ];
506
- result = await evaluateMessageSecurityWithSeal(rawMime, unlocked, pinned, primaryAddress, {
507
- mailboxUid: message.mailboxUid,
508
- messageUid: message.uid,
509
- ...currentVerificationSeal(message),
510
- masterKeyGeneration: sealContext.generation,
511
- signerKeys,
512
- }).catch(() =>
513
- // Only a lock while sealing throws: evaluate as locked (the lock itself re-evaluates too).
514
- evaluateMessageSecurity(rawMime, undefined, pinned, primaryAddress),
515
- );
516
- if (result.sealToWrite) {
517
- // Best effort and in the background: never awaited, never shown.
518
- void sendVerificationSeal(message.uid, result.sealToWrite);
519
- }
520
- } else {
521
- result = await evaluateMessageSecurity(rawMime, unlocked, pinned, primaryAddress);
522
- }
523
- // Only one reader address can be checked per evaluation: a message sent to one of this mailbox's
524
- // aliases isn't "not addressed to you", so each alias is tried before saying so.
525
- for (const alias of aliasAddresses) {
526
- if (!result.notAddressedToReader) {
527
- break;
528
- }
529
- const viaAlias = await evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
530
- result = { ...result, notAddressedToReader: viaAlias.notAddressedToReader };
531
- }
532
- const unpinned = contactPins.loaded && pins.length === 0;
533
- const keyChanged = signerKeyChanged(result);
534
- const [keyState, access] = await Promise.all([
535
- keyChanged || (unpinned && UNVERIFIED_SIGNER_STATES.has(result.state)) ? loadKeyState() : undefined,
536
- keyChanged ? getMyMailboxAccess(message.mailboxUid).then((a) => a.canUpdate, () => undefined) : undefined,
537
- ]);
538
- if (!cancelled) {
539
- setSecurity(result);
540
- setSenderUnpinned(unpinned);
541
- setSenderKeyState(keyState);
542
- setCanUpdateMailbox(access);
543
- }
544
- })
545
- .catch(() => {
546
- if (!cancelled) {
547
- setSecurity(
548
- message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
549
- );
550
- }
551
- });
552
- return () => {
553
- cancelled = true;
554
- };
555
- }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey]);
556
-
557
- // Decrypted plaintext must not outlive the key session that produced it: the moment this mailbox's
558
- // keys are destroyed (logout, idle timeout, explicit lock), drop the recovered html/text and
559
- // re-evaluate - an encrypted message then falls back to its "Unlock to view" state.
560
- useEffect(
561
- () =>
562
- subscribeKeySession((event) => {
563
- if (event.mailboxUid === message.mailboxUid && event.state === "locked") {
564
- setSecurity(null);
565
- setUnlockRefresh((n) => n + 1);
566
- }
567
- }),
568
- [message.mailboxUid],
569
- );
570
-
571
- // While restapi's ScheduledSendJob holds a send lease the message is being relayed: cancelling or moving it would be
572
- // refused (409/403), so the Outbox controls are replaced by "Sending...". When the lease runs out the message is
573
- // re-read - it has either been sent (and left Outbox) or been released back to its schedule.
574
- const leaseExpiresMs = message.scheduledSendLeaseExpiresAt ? Date.parse(message.scheduledSendLeaseExpiresAt) : NaN;
575
- const sendInProgress = inOutbox && leaseExpiresMs > nowMs;
576
- useEffect(() => {
577
- if (!sendInProgress) {
578
- return;
579
- }
580
- const timer = setTimeout(() => {
581
- setNowMs(Date.now());
582
- void reloadMessage();
583
- }, leaseExpiresMs - nowMs + 1);
584
- return () => clearTimeout(timer);
585
- }, [sendInProgress, leaseExpiresMs, nowMs]);
586
-
587
- async function reloadMessage() {
588
- try {
589
- setReloaded(await getMessage(message.uid));
590
- setNowMs(Date.now());
591
- } catch {
592
- // Keep showing what we have - the next refused action or lease expiry tries again.
593
- }
594
- }
595
-
596
- // Offered only when this device genuinely has no unlocked session for this message's mailbox at all
597
- // (as opposed to being unlocked but still unable to decrypt - a wrong/since-rotated key, which
598
- // re-unlocking the same session can't fix) - see `evaluateMessageSecurity()`'s own doc comment on why
599
- // `decryptError` alone can't distinguish those two cases.
600
- async function handleUnlockToView() {
601
- const mailboxUid = message.mailboxUid;
602
- const mailboxKeys = mailboxes.find((mb) => mb.uid === mailboxUid)?.keys ?? [];
603
- try {
604
- await requestUnlock(mailboxUid, mailboxKeys);
605
- setUnlockRefresh((n) => n + 1);
606
- } catch {
607
- // User dismissed the unlock dialog - security state stays exactly as it was.
608
- }
609
- }
610
-
611
- // Only ever invoked from the "Trust this signer" dialog, which only opens for an unverified-signer result carrying
612
- // `signerCertificate`. The pin goes on the contact in the mailbox the message belongs to; the evaluation is then
613
- // re-run against a fresh pinned-signer lookup, so the badge turns verified.
614
- async function handleTrustSigner(address: string, certificate: string) {
615
- setTrusting(true);
616
- setTrustError(null);
617
- try {
618
- await trustSigner(message.mailboxUid, { address, certificate });
619
- clearPinnedSignerCache();
620
- setTrustConfirmOpen(false);
621
- setUnlockRefresh((n) => n + 1);
622
- } catch (err) {
623
- setTrustError(trustSignerErrorMessage(err));
624
- } finally {
625
- setTrusting(false);
626
- }
627
- }
628
-
629
- // After a key change was accepted or kept (or the pinned key moved meanwhile): the pinned signers are re-read and
630
- // the message re-evaluated, so an accepted key turns the badge verified.
631
- function refreshAfterKeyChange(notice: string | null) {
632
- setKeyChangeNotice(notice);
633
- clearPinnedSignerCache();
634
- setUnlockRefresh((n) => n + 1);
635
- }
636
-
637
- // Reply, Reply All and Forward first load the full body to quote (see `loadQuotedBody()`), so the buttons are
638
- // disabled meanwhile - a second click would open a second window. An encrypted original keeps its reply
639
- // encrypted, since the quote may carry its decrypted content.
640
- const [preparingCompose, setPreparingCompose] = useState(false);
641
-
642
- /** The replying mailbox's own addresses (primary and aliases), which a reply never goes to - from the mail shell's
643
- * mailbox list, else fetched; none when neither is available. */
644
- async function ownAddresses(): Promise<string[]> {
645
- const mailbox = readerMailbox ?? (await getMailbox(message.mailboxUid).catch(() => undefined));
646
- return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
647
- }
648
-
649
- async function handleReplyOrForward(kind: "reply" | "replyAll" | "forward") {
650
- setPreparingCompose(true);
651
- try {
652
- const [original, own] = await Promise.all([
653
- loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
654
- kind === "forward" ? [] : ownAddresses(),
655
- ]);
656
- const encrypt = !!message.encrypted;
657
- // What makes the new message part of this one's thread rather than a conversation of its own:
658
- // the server composes the MIME from the recipients, subject and HTML alone, so nothing else
659
- // recovers what is being replied to. A forward carries it for the same reason - it continues
660
- // the thread it came from, which is where its recipient will file the reply to it.
661
- const threading = buildReplyThreading(message);
662
- if (kind === "forward") {
663
- openCompose({
664
- mailboxUid: message.mailboxUid,
665
- subject: forwardSubject(message.subject),
666
- quotedHtml: buildForwardQuote(message, original.body),
667
- signatureContext: "reply_forward",
668
- encrypt,
669
- threading,
670
- });
671
- return;
672
- }
673
- // The message's own recipients are only the envelope recipient of a delivered message, so Reply All
674
- // answers whoever its headers actually name, with anything the record holds that they don't.
675
- const known = new Set((original.recipients ?? []).map((r) => r.address.trim().toLowerCase()));
676
- const recipients = buildReplyRecipients(
677
- { ...message, recipients: [...(original.recipients ?? []), ...message.recipients.filter((r) => !known.has(r.address.trim().toLowerCase()))] },
678
- own,
679
- kind === "replyAll",
680
- );
681
- const format = (list: Recipient[]) => list.map((r) => formatRecipient(r)).join(", ");
682
- openCompose({
683
- mailboxUid: message.mailboxUid,
684
- to: format(recipients.to),
685
- cc: recipients.cc.length > 0 ? format(recipients.cc) : undefined,
686
- subject: replySubject(message.subject),
687
- quotedHtml: buildReplyQuote(message, original.body),
688
- signatureContext: "reply_forward",
689
- suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
690
- encrypt,
691
- threading,
692
- });
693
- } finally {
694
- setPreparingCompose(false);
695
- }
696
- }
697
-
698
- async function handleRecall() {
699
- // Only ever invoked from the confirmation modal below, which itself only renders once `message`
700
- // is loaded (the early return above covers the only other state) — the non-null assertion
701
- // reflects that real invariant, matching this codebase's established pattern for the same class
702
- // of always-true-in-practice guard.
703
- setRecalling(true);
704
- setError(null);
705
- try {
706
- const updated = await recallMessage(message.uid);
707
- setConfirming(false);
708
- onRecalled?.(updated);
709
- } catch (err) {
710
- setError(err instanceof ApiRequestError ? err.message : "Could not recall this message.");
711
- } finally {
712
- setRecalling(false);
713
- }
714
- }
715
-
716
- // Only ever invoked from the "Cancel" button below (`isOutbox` and `message.scheduledSendTime` both truthy -
717
- // `draftsFolderUid` is required whenever `isOutbox` is, see the prop's own doc comment), or from "Move to
718
- // Drafts" (an Outbox message with no active schedule, which checks `draftsFolderUid` itself), so the non-null
719
- // assertion reflects a real invariant, matching `handleRecall`'s identical pattern just above.
720
- async function handleCancelScheduledSend() {
721
- setCanceling(true);
722
- setCancelError(null);
723
- try {
724
- const updated = await cancelScheduledSend(message, draftsFolderUid!);
725
- // Keeps a `folder:`-scoped Tier 2 local search from still finding it in its old folder.
726
- void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
727
- onScheduledSendCanceled?.(updated);
728
- } catch (err) {
729
- const fallback = message.scheduledSendTime ? "Could not cancel this scheduled send." : "Could not move this message to Drafts.";
730
- setCancelError(err instanceof ApiRequestError ? err.message : fallback);
731
- // A conflict or refusal usually means the send job claimed (or already relayed) the message meanwhile -
732
- // re-read it so the pane shows "Sending..." or its new state instead of the stale controls.
733
- if (err instanceof ApiRequestError && (err.status === 409 || err.status === 403)) {
734
- void reloadMessage();
735
- }
736
- } finally {
737
- setCanceling(false);
738
- }
739
- }
740
-
741
- // Only ever invoked from the Archive button below, which itself only renders once `message` is
742
- // loaded and this isn't a Drafts/Outbox message (the button's own guard mirrors
743
- // `BaseMessageRoute.archive()`'s server-side 400) — same real invariant as `handleRecall` above.
744
- async function handleArchive() {
745
- setArchiving(true);
746
- setArchiveError(null);
747
- try {
748
- const updated = await archiveMessage(message.uid);
749
- void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
750
- onArchived?.(updated);
751
- } catch (err) {
752
- setArchiveError(err instanceof ApiRequestError ? err.message : "Could not archive this message.");
753
- } finally {
754
- setArchiving(false);
755
- }
756
- }
757
-
758
- // Only ever invoked from the Labels menu below, which itself only renders once `message` is loaded.
759
- // Several labels are ticked in the menu and saved together here, in one `setMessageLabels()` call:
760
- // that call replaces the whole `labelUids` list rather than patching one entry, so a menu that saved
761
- // per tick would send a request (and burn an optimistic-lock `version`) for every single tick.
762
- //
763
- // The list is computed from the newest copy known (`latestLabelsMessageRef`, never a stale render
764
- // closure), so a save that follows another still carries the `version` the server last handed back.
765
- async function handleApplyLabels(labelUids: string[]) {
766
- setSavingLabels(true);
767
- setLabelsError(null);
768
- try {
769
- const base = message.version >= latestLabelsMessageRef.current.version ? message : latestLabelsMessageRef.current;
770
- const updated = await setMessageLabels(base, labelUids);
771
- latestLabelsMessageRef.current = updated;
772
- setLabelsMessage(updated);
773
- onLabelsChanged?.(updated);
774
- } catch (err) {
775
- setLabelsError(err instanceof ApiRequestError ? err.message : "Could not update this message's labels.");
776
- } finally {
777
- setSavingLabels(false);
778
- }
779
- }
780
-
781
- /**
782
- * Moves this message into the folder picked in the Move to prompt. Deliberately *not* caught here:
783
- * `MoveToFolderDialog` shows a failure beside the destination that would retry it, and closes only
784
- * once the move has actually landed.
785
- *
786
- * The move is awaited on its own line rather than written as `onMoved?.(await moveMessage(...))` -
787
- * an optional call whose callee is absent never evaluates its arguments at all, so a caller that
788
- * passes no `onMoved` would have had nothing moved while the prompt closed as if it had.
789
- */
790
- async function handleMove(folderUid: string) {
791
- const updated = await moveMessage(message, folderUid);
792
- onMoved?.(updated);
793
- }
794
-
795
- // Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
796
- // is loaded — same real invariant as every other handler above.
797
- async function handleReceipt(type: ReceiptType, action: "approve" | "decline") {
798
- setReceiptBusy(type);
799
- setReceiptError(null);
800
- try {
801
- const updated = await (action === "approve" ? approveReceipt : declineReceipt)(message.uid, type);
802
- onReceiptHandled?.(updated);
803
- } catch (err) {
804
- setReceiptError(err instanceof ApiRequestError ? err.message : "Could not handle this receipt request.");
805
- } finally {
806
- setReceiptBusy(null);
807
- }
808
- }
809
-
810
- const verified = security !== null && VERIFIED_STATES.has(security.state);
811
- // A seal proves the signature verified when first opened, and the live check still passed everything but the signer
812
- // key's status - so the signed Subject and attachments are shown as for a verified message, under its own badge.
813
- const verifiedOrSealed = verified || security?.state === "verified_at_first_open";
814
- // Under a verified badge, the Subject shown is the one the signature covers (RFC 9788 protected headers), when
815
- // the message carries one - the outer Subject is unsigned and anyone relaying the message could change it.
816
- const protectedSubject = verifiedOrSealed ? security.protectedHeaders?.subject : undefined;
817
- // Only meaningful for signed-only mail: an encrypted message's outer Subject is deliberately obscured. Not a
818
- // signature failure (mailing lists legitimately tag subjects), just worth pointing out.
819
- const subjectDiffers = protectedSubject !== undefined && (security!.state === "signed_verified" || security!.sealedState === "signed_verified") && protectedSubject !== message.subject;
820
- // Attachments inside the signed/decrypted entity. Under a verified badge only these are listed - the server's
821
- // attachment records also include parts outside the signature, which the badge doesn't vouch for. For decrypted
822
- // mail the server only ever saw the encrypted blob, so these are the real attachments.
823
- // A decrypted message whose signature failed still only has these (the server list is just `smime.p7m`); they're
824
- // listed with a warning. A signed-only failure never carries recovered attachments.
825
- const innerAttachments =
826
- security?.attachments !== undefined &&
827
- (verifiedOrSealed || security.state === "encrypted" || security.state === "encrypted_unverified_signer" || security.state === "signature_failed")
828
- ? security.attachments
829
- : undefined;
830
- // Any state that involves a signature shows the address actually signed for (the protected From when the message
831
- // carries one - it equals the outer From's address, or verification would have failed) next to the badge, never a
832
- // display name alone: anyone can put "ceo@corp.com" in the name of a message sent from x@corp-pay.com.
833
- const signatureShown = security !== null && security.state !== "unprotected" && security.state !== "encrypted";
834
- const senderAddress = (signatureShown ? extractAddresses(security.protectedHeaders?.from)[0] : undefined) ?? message.from.address;
835
- const senderName = message.from.displayName;
836
- const senderNameCheck = checkSenderName(senderName, senderAddress);
837
- const showSenderAddress = !!senderName && (signatureShown || senderNameCheck.looksLikeAddress);
838
- const senderLabel = showSenderAddress ? `${senderName} <${senderAddress}>` : senderName || senderAddress;
839
- // Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
840
- // A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
841
- const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
842
- const trustableCertificate =
843
- security !== null && UNVERIFIED_SIGNER_STATES.has(security.state) && senderUnpinned && !pendingConflict
844
- ? security.signerCertificate
845
- : undefined;
846
- const keyChanged = security && signerKeyChanged(security) ? security : undefined;
847
- const pinnedSignerKey = senderKeyState?.pinned[0];
848
- const recordedConflict =
849
- keyChanged && sameFingerprint(senderKeyState?.conflict?.observedKey.fingerprint, keyChanged.signerFingerprint)
850
- ? senderKeyState!.conflict
851
- : undefined;
852
-
853
- const shownSubject = (protectedSubject ?? message.subject) || "(no subject)";
854
- /**
855
- * How tall the message body is - the one thing on this pane worth every pixel it can have.
856
- *
857
- * The same rule in both places: the pane is a full-height flex column and the body is its one growing
858
- * child, so it fills whatever the header leaves (`min-h-0` on both, or a tall body would stretch the
859
- * column past the pane instead of scrolling inside it). Inside a thread that height comes from the
860
- * message's own row, which `ConversationThreadPane` gives `min-h-full` of its scrolling list - so the
861
- * height is still the window's, resolved down the flex chain rather than written here as a `vh`
862
- * number, and it follows a resize with no breakpoint to cross.
863
- *
864
- * Why not size it to its content: that would need the document *inside* the frame to measure and
865
- * report itself, and these frames are `sandbox=""` - scripts off - because they render mail from
866
- * strangers. So a message longer than the pane keeps its own scrollbar, and everything shorter shows
867
- * whole with the frame reaching the bottom of the pane either way.
868
- */
869
- const bodyClassName = "flex-1 min-h-0 w-full";
870
-
871
- return (
872
- // `h-full` outside a thread only: the standalone message route renders this pane straight into a
873
- // *block* (`MailShell`'s own `<main>`), where nothing stretches it and a percentage of that block's
874
- // own resolved height is the height. Inside a thread the parent is a flex row whose own height came
875
- // out of the flex algorithm - Chrome won't resolve a percentage against that, and an explicit
876
- // height would also opt the pane out of the stretching that does size it correctly there.
877
- <div className={`flex-1 min-w-0 min-h-0 flex flex-col${inThread ? "" : " h-full"}`}>
878
- <div className="border-b border-border p-4">
879
- {backHref && (
880
- <a href={backHref} className="text-sm text-primary-dark hover:underline block mb-2">
881
- &larr; Back to messages
882
- </a>
883
- )}
884
- <div className="flex items-start justify-between gap-3">
885
- <div className="flex items-center gap-2 min-w-0">
886
- {/* One `h1` per document: inside a thread the conversation's own subject is it. */}
887
- {inThread ? (
888
- <h3 className="text-sm font-semibold truncate">{shownSubject}</h3>
889
- ) : (
890
- <h1 className="text-lg font-bold tracking-tight truncate">{shownSubject}</h1>
891
- )}
892
- {security && <SecurityIndicator security={security} />}
893
- </div>
894
- {sendInProgress && (
895
- <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
896
- Sending&hellip;
897
- </span>
898
- )}
899
- {isSentItems &&
900
- (message.recallRequestedAt ? (
901
- <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
902
- Recall requested
903
- </span>
904
- ) : (
905
- <Button
906
- type="button"
907
- variant="secondary"
908
- className="!w-auto shrink-0"
909
- onClick={() => setConfirming(true)}
910
- >
911
- Recall this message
912
- </Button>
913
- ))}
914
- {inOutbox && !sendInProgress && message.scheduledSendTime && (
915
- <div className="flex items-center gap-2 shrink-0">
916
- <span className="text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt">
917
- Scheduled for {new Date(message.scheduledSendTime).toLocaleString()}
918
- </span>
919
- <Button
920
- type="button"
921
- variant="secondary"
922
- className="!w-auto"
923
- loading={canceling}
924
- disabled={canceling}
925
- onClick={handleCancelScheduledSend}
926
- >
927
- Cancel
928
- </Button>
929
- </div>
930
- )}
931
- {/* A message left in Outbox with no active schedule - a scheduled send that failed or was refused
932
- (restapi's ScheduledSendJob clears scheduledSendTime and leaves it there), which can't be sent
933
- again from Outbox (409) or archived - would otherwise be stuck. Moving it back to Drafts works for
934
- any Outbox message. */}
935
- {inOutbox && !sendInProgress && !message.scheduledSendTime && draftsFolderUid && (
936
- <Button
937
- type="button"
938
- variant="secondary"
939
- className="!w-auto shrink-0"
940
- loading={canceling}
941
- disabled={canceling}
942
- onClick={handleCancelScheduledSend}
943
- >
944
- Move to Drafts
945
- </Button>
946
- )}
947
- </div>
948
- {inOutbox && message.scheduledSendError && (
949
- <div className="mt-2">
950
- <Alert>This message wasn&rsquo;t sent: {message.scheduledSendError}</Alert>
951
- </div>
952
- )}
953
- {cancelError && (
954
- <div className="mt-2">
955
- <Alert>{cancelError}</Alert>
956
- </div>
957
- )}
958
- {keyChangeNotice && (
959
- <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
960
- {keyChangeNotice}
961
- </p>
962
- )}
963
- {security && UNVERIFIED_SIGNER_STATES.has(security.state) && (
964
- // Informational: the signature is intact, but nothing ties its certificate to this sender - anyone can
965
- // create a certificate naming any address. "Trust this signer" (below) pins it once the reader has
966
- // confirmed the fingerprint.
967
- <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
968
- Signed, but the signer isn&rsquo;t a trusted contact key, so the sender isn&rsquo;t verified.
969
- {security.signerEmails && security.signerEmails.length > 0 && <> Certificate for {security.signerEmails.join(", ")}.</>}
970
- {security.signerFingerprint && (
971
- <>
972
- {" "}
973
- Fingerprint <span className="font-mono text-xs break-all">{security.signerFingerprint}</span>.
974
- </>
975
- )}
976
- </p>
977
- )}
978
- {pendingConflict && (
979
- // Only an unverified signer's result loads the key state while the sender is unpinned, and a conflict
980
- // always comes from a matching contact, so its uid is known.
981
- <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
982
- This sender has a signing key change waiting for your review, so this signer can&rsquo;t be trusted from
983
- here.{" "}
984
- <a
985
- href={`/contacts/${encodeURIComponent(senderKeyState.contactUid!)}`}
986
- className="font-medium text-primary-dark hover:underline"
987
- >
988
- Review it in Contacts
989
- </a>
990
- </p>
991
- )}
992
- {trustableCertificate && (
993
- <div className="mt-1.5">
994
- <Button
995
- type="button"
996
- variant="secondary"
997
- className="!w-auto"
998
- disabled={trusting}
999
- onClick={() => {
1000
- setTrustError(null);
1001
- setTrustConfirmOpen(true);
1002
- }}
1003
- >
1004
- Trust this signer
1005
- </Button>
1006
- </div>
1007
- )}
1008
- {subjectDiffers && (
1009
- <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1010
- The subject shown above is the one the sender signed. It differs from the subject this message was
1011
- delivered with (&ldquo;{message.subject}&rdquo;), which may have been changed on the way, e.g. by a
1012
- mailing list.
1013
- </p>
1014
- )}
1015
- {security?.headerTamperDetected && (
1016
- <div className="mt-2">
1017
- <Alert>
1018
- This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or
1019
- encrypted — an intermediary may have altered them after sending. Treat the fields shown above with
1020
- caution.
1021
- </Alert>
1022
- </div>
1023
- )}
1024
- {security?.state === "verified_at_first_open" && (
1025
- // A seal outranks only a key-status failure (see `evaluateMessageSecurityWithSeal()`). A signer key
1026
- // later reported compromised is a warning, never reassurance.
1027
- <p
1028
- role="status"
1029
- className={`mt-2 py-2 px-3 rounded-sm text-sm text-text ${security.laterCompromised ? "bg-warning/15" : "bg-surface-alt"}`}
1030
- >
1031
- {verifiedAtFirstOpenMessage(security)}
1032
- </p>
1033
- )}
1034
- {keyChanged && (
1035
- <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">
1036
- <h2 className="font-semibold">This sender&rsquo;s signing key changed</h2>
1037
- <p>
1038
- The signature on this message is valid and its certificate names {senderAddress}, but it was made
1039
- with a different key than the one you trust for this sender
1040
- {keyChanged.state === "verified_at_first_open" ? "." : <>, so it isn&rsquo;t verified.</>}
1041
- </p>
1042
- <KeyChangeReview
1043
- mailboxUid={message.mailboxUid}
1044
- address={senderAddress}
1045
- useType="sign"
1046
- ownerName="the sender"
1047
- current={pinnedSignerKey && { fingerprint: pinnedSignerKey.fingerprint, since: senderKeyState.pinnedSince! }}
1048
- proposed={{
1049
- fingerprint: keyChanged.signerFingerprint!,
1050
- emails: keyChanged.signerEmails,
1051
- observedAt: recordedConflict?.observedAt,
1052
- source: recordedConflict?.source,
1053
- }}
1054
- certificate={keyChanged.signerCertificate}
1055
- canReject={recordedConflict !== undefined}
1056
- canResolve={canUpdateMailbox}
1057
- onResolved={(action) => refreshAfterKeyChange(action === "reject" ? KEPT_CURRENT_SIGNING_KEY_MESSAGE : null)}
1058
- onPinnedKeyChanged={() => refreshAfterKeyChange(KEY_CHANGE_STALE_MESSAGE)}
1059
- />
1060
- </section>
1061
- )}
1062
- {security?.state === "signature_failed" && !keyChanged && (
1063
- // Deliberately an informational notice, not an error `Alert`: an unverifiable signature
1064
- // means "don't trust the signer", not "this message is broken" - the body stays readable.
1065
- <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1066
- {security.signatureFailureReason
1067
- ? SIGNATURE_FAILURE_MESSAGE[security.signatureFailureReason]
1068
- : GENERIC_SIGNATURE_FAILURE_MESSAGE}
1069
- </p>
1070
- )}
1071
- {verifiedOrSealed && !security.protectedHeaders && (
1072
- // A legacy S/MIME sender signs only the body: the outer Subject/To/Cc shown here were never signed.
1073
- <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1074
- The signature covers this message&rsquo;s content and attachments only. Its Subject, To and Cc
1075
- weren&rsquo;t signed, so they could have been changed after it was sent.
1076
- </p>
1077
- )}
1078
- {security?.notAddressedToReader && (
1079
- // Informational, like the notice above: a Bcc recipient legitimately sees this too.
1080
- <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1081
- The recipients this message was signed or encrypted for don&rsquo;t include this mailbox - it may have
1082
- been forwarded or re-sent to you unchanged, or you were Bcc&rsquo;d.
1083
- </p>
1084
- )}
1085
- <p className="text-sm text-text-muted mt-1">
1086
- From {senderLabel} &middot; {new Date(message.receivedDate).toLocaleString()}
1087
- </p>
1088
- {senderNameCheck.misleading && (
1089
- <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
1090
- The sender&rsquo;s name &ldquo;{senderName}&rdquo; looks like an email address, but this message was
1091
- sent from <span className="font-medium">{senderAddress}</span>. Don&rsquo;t trust it based on the name.
1092
- </p>
1093
- )}
1094
- <p className="text-sm text-text-muted">
1095
- To {message.recipients.map((r) => r.displayName || r.address).join(", ")}
1096
- </p>
1097
- {/* Wraps: in a thread the pane can be as narrow as the reading pane gets (the list takes
1098
- 384px of it), and these are six controls. */}
1099
- <div className="flex flex-wrap gap-1 mt-3">
1100
- <IconAction
1101
- icon={<HiOutlineArrowUturnLeft size={16} aria-hidden="true" />}
1102
- label="Reply"
1103
- disabled={preparingCompose}
1104
- onClick={() => void handleReplyOrForward("reply")}
1105
- />
1106
- <IconAction
1107
- icon={
1108
- // The conventional reply-all glyph: the reply arrow, doubled. `hi2` has no
1109
- // reply-all icon of its own, and nothing else in it means "answer everyone".
1110
- <span className="inline-flex items-center" aria-hidden="true">
1111
- <HiOutlineArrowUturnLeft size={16} />
1112
- <HiOutlineArrowUturnLeft size={16} className="-ml-2.5" />
1113
- </span>
1114
- }
1115
- label="Reply All"
1116
- disabled={preparingCompose}
1117
- onClick={() => void handleReplyOrForward("replyAll")}
1118
- />
1119
- <IconAction
1120
- icon={<HiOutlineArrowUturnRight size={16} aria-hidden="true" />}
1121
- label="Forward"
1122
- disabled={preparingCompose}
1123
- onClick={() => void handleReplyOrForward("forward")}
1124
- />
1125
- {!inOutbox && message.folderUid !== draftsFolderUid && (
1126
- <IconAction
1127
- icon={<HiOutlineArchiveBox size={16} aria-hidden="true" />}
1128
- label="Archive"
1129
- busy={archiving}
1130
- disabled={archiving}
1131
- onClick={handleArchive}
1132
- />
1133
- )}
1134
- {folders && folders.length > 0 && (
1135
- <IconAction
1136
- icon={<HiOutlineFolderArrowDown size={16} aria-hidden="true" />}
1137
- label="Move to"
1138
- onClick={() => setMovePrompt(true)}
1139
- />
1140
- )}
1141
- {labels && labels.length > 0 && (
1142
- <LabelMenuButton
1143
- aria-label="Labels"
1144
- label="Labels"
1145
- className="border border-border py-1.5"
1146
- labels={labels}
1147
- mailboxUid={message.mailboxUid}
1148
- onLabelCreated={onLabelCreated}
1149
- applied={currentLabelsMessage.labelUids ?? []}
1150
- onCommit={(labelUids) => void handleApplyLabels(labelUids)}
1151
- busy={savingLabels}
1152
- note="Ticked labels are applied to this message and unticked ones removed."
1153
- emptyNote="This mailbox has no labels yet."
1154
- commit={{ label: "Apply" }}
1155
- clear={{ label: "Remove all labels" }}
1156
- />
1157
- )}
1158
- </div>
1159
- {archiveError && (
1160
- <div className="mt-2">
1161
- <Alert>{archiveError}</Alert>
1162
- </div>
1163
- )}
1164
- {labelsError && (
1165
- <div className="mt-2">
1166
- <Alert>{labelsError}</Alert>
1167
- </div>
1168
- )}
1169
- {labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (
1170
- <div className="flex flex-wrap items-center gap-1.5 mt-2">
1171
- {currentLabelsMessage
1172
- .labelUids!.map((uid) => labels.find((l) => l.uid === uid))
1173
- .filter((l): l is Label => !!l)
1174
- .map((l) => (
1175
- <span
1176
- key={l.uid}
1177
- className="inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt"
1178
- >
1179
- <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
1180
- {l.name}
1181
- </span>
1182
- ))}
1183
- </div>
1184
- )}
1185
- {/* A failed reclassification is reported inside the confirmation dialog it was started
1186
- from, next to the Move button that would retry it - not out here behind it. */}
1187
- {(["delivery", "read"] as const)
1188
- .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
1189
- .map((type) => (
1190
- <div
1191
- key={type}
1192
- className="flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm"
1193
- >
1194
- <span>
1195
- {senderLabel} requested a {type} receipt for this
1196
- message.
1197
- </span>
1198
- <Button
1199
- type="button"
1200
- variant="secondary"
1201
- className="!w-auto"
1202
- loading={receiptBusy === type}
1203
- disabled={receiptBusy !== null}
1204
- onClick={() => handleReceipt(type, "approve")}
1205
- >
1206
- Send receipt
1207
- </Button>
1208
- <Button
1209
- type="button"
1210
- variant="text"
1211
- disabled={receiptBusy !== null}
1212
- onClick={() => handleReceipt(type, "decline")}
1213
- >
1214
- Decline
1215
- </Button>
1216
- </div>
1217
- ))}
1218
- {receiptError && (
1219
- <div className="mt-2">
1220
- <Alert>{receiptError}</Alert>
1221
- </div>
1222
- )}
1223
- {security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (
1224
- <p role="status" className="mt-3 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
1225
- These attachments come from a message whose signature couldn&rsquo;t be verified. Open them only if
1226
- you trust the sender.
1227
- </p>
1228
- )}
1229
- {innerAttachments
1230
- ? innerAttachments.length > 0 && (
1231
- <ul className="flex flex-wrap gap-2 mt-3">
1232
- {innerAttachments.map((attachment, index) => (
1233
- <li key={`${index}:${attachment.filename ?? ""}`}>
1234
- <button
1235
- type="button"
1236
- onClick={() => downloadMimeAttachment(attachment)}
1237
- className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
1238
- >
1239
- {attachment.filename ?? `Unnamed ${attachment.contentType} attachment`}
1240
- </button>
1241
- </li>
1242
- ))}
1243
- </ul>
1244
- )
1245
- : attachments.length > 0 && (
1246
- <ul className="flex flex-wrap gap-2 mt-3">
1247
- {attachments.map((attachment) => (
1248
- <li key={attachment.uid}>
1249
- <a
1250
- href={attachmentContentUrl(attachment.uid)}
1251
- className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
1252
- >
1253
- {attachment.filename} ({formatBytes(attachment.sizeBytes)})
1254
- </a>
1255
- </li>
1256
- ))}
1257
- </ul>
1258
- )}
1259
- </div>
1260
- {security?.decryptError && (
1261
- <div className="px-4 pt-2">
1262
- <Alert>{security.decryptError}</Alert>
1263
- {!getUnlockedKeys(message.mailboxUid) && (
1264
- <button
1265
- type="button"
1266
- onClick={handleUnlockToView}
1267
- className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
1268
- >
1269
- <HiOutlineLockClosed size={12} aria-hidden="true" />
1270
- Unlock to view this message
1271
- </button>
1272
- )}
1273
- </div>
1274
- )}
1275
- {security?.text !== undefined ? (
1276
- // A recovered text/plain body renders as text (React escapes it) - never as markup.
1277
- <pre
1278
- aria-label={message.subject || "Message content"}
1279
- className={`${bodyClassName} overflow-auto p-4 m-0 text-sm font-sans whitespace-pre-wrap break-words`}
1280
- >
1281
- {security.text}
1282
- </pre>
1283
- ) : security?.html !== undefined ? (
1284
- // A decrypted/verified body never came through the server's own sanitize-html pass (it
1285
- // couldn't - the server never saw the plaintext) - it's sanitized here, client-side, before
1286
- // it ever touches the DOM (remote images/stylesheets/`url()`s stripped too, so opening it
1287
- // can't ping a tracker), behind a no-remote-loads CSP, on top of the iframe's `sandbox=""`.
1288
- <iframe
1289
- key={message.uid}
1290
- title={message.subject || "Message content"}
1291
- srcDoc={buildSecureSrcDoc(security.html)}
1292
- sandbox=""
1293
- className={`${bodyClassName} border-0`}
1294
- />
1295
- ) : (
1296
- <iframe
1297
- key={message.uid}
1298
- title={message.subject || "Message content"}
1299
- src={`/api/mail/messages/${encodeURIComponent(message.uid)}/content`}
1300
- sandbox=""
1301
- className={`${bodyClassName} border-0`}
1302
- />
1303
- )}
1304
-
1305
- <MoveToFolderDialog
1306
- open={movePrompt}
1307
- onClose={() => setMovePrompt(false)}
1308
- mailboxUid={message.mailboxUid}
1309
- folders={folders ?? []}
1310
- currentFolderUid={message.folderUid}
1311
- onMove={handleMove}
1312
- onFolderCreated={onFolderCreated}
1313
- />
1314
- <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
1315
- <p className="text-sm text-text-muted mb-4">
1316
- This asks every original recipient's mail system to delete their copy, but only if it's
1317
- still unread there — there's no way to guarantee it, and no confirmation once it either
1318
- succeeds or fails. Recipients who already read the message will keep it.
1319
- </p>
1320
- {error && <Alert>{error}</Alert>}
1321
- <div className="flex gap-3">
1322
- <Button type="button" loading={recalling} disabled={recalling} onClick={handleRecall} className="!w-auto">
1323
- Recall message
1324
- </Button>
1325
- <Button
1326
- type="button"
1327
- variant="secondary"
1328
- disabled={recalling}
1329
- onClick={() => setConfirming(false)}
1330
- className="!w-auto"
1331
- >
1332
- Cancel
1333
- </Button>
1334
- </div>
1335
- </Modal>
1336
- <Modal
1337
- open={trustConfirmOpen && !!trustableCertificate}
1338
- onClose={() => !trusting && setTrustConfirmOpen(false)}
1339
- title="Trust this signer?"
1340
- >
1341
- <div className="flex flex-col gap-3 text-sm">
1342
- <p>
1343
- Mail from <span className="font-medium">{senderAddress}</span> signed with this certificate will show as
1344
- verified.
1345
- </p>
1346
- <dl className="flex flex-col gap-1">
1347
- <dt className="text-xs text-text-muted">Certificate issued to</dt>
1348
- <dd>{security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address"}</dd>
1349
- <dt className="text-xs text-text-muted">Fingerprint</dt>
1350
- <dd className="font-mono text-xs break-all">
1351
- {security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown"}
1352
- </dd>
1353
- </dl>
1354
- <p className="text-text-muted">
1355
- Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with
1356
- the sender through another channel, such as a phone call.
1357
- </p>
1358
- {trustError && <Alert>{trustError}</Alert>}
1359
- <div className="flex gap-3">
1360
- <Button
1361
- type="button"
1362
- className="!w-auto"
1363
- loading={trusting}
1364
- disabled={trusting}
1365
- onClick={() => handleTrustSigner(senderAddress, trustableCertificate!)}
1366
- >
1367
- Trust
1368
- </Button>
1369
- <Button
1370
- type="button"
1371
- variant="secondary"
1372
- className="!w-auto"
1373
- disabled={trusting}
1374
- onClick={() => setTrustConfirmOpen(false)}
1375
- >
1376
- Cancel
1377
- </Button>
1378
- </div>
1379
- </div>
1380
- </Modal>
1381
- </div>
1382
- );
1383
- }
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 {
7
+ HiOutlineArchiveBox,
8
+ HiOutlineArrowUturnLeft,
9
+ HiOutlineArrowUturnRight,
10
+ HiOutlineCheck,
11
+ HiOutlineExclamationTriangle,
12
+ HiOutlineFolderArrowDown,
13
+ HiOutlineLockClosed,
14
+ } from "react-icons/hi2";
15
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
16
+ import {
17
+ Attachment,
18
+ Folder,
19
+ Message,
20
+ ReceiptType,
21
+ Recipient,
22
+ approveReceipt,
23
+ archiveMessage,
24
+ attachmentContentUrl,
25
+ cancelScheduledSend,
26
+ declineReceipt,
27
+ getMailbox,
28
+ getMessage,
29
+ getMessageRawContent,
30
+ moveMessage,
31
+ recallMessage,
32
+ setMessageLabels,
33
+ } from "@rapidmx/react-shared/mail/mailApi.js";
34
+ import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
35
+ import {
36
+ buildForwardQuote,
37
+ buildReplyQuote,
38
+ buildReplyRecipients,
39
+ buildReplyThreading,
40
+ forwardSubject,
41
+ replySubject,
42
+ } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
43
+ import { sanitizeMessageBodyHtml } from "@rapidmx/react-shared/mail/messageBodySanitizer.js";
44
+ import MoveToFolderDialog from "./MoveToFolderDialog.js";
45
+ import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
46
+ import type { MessageSecurityResult, SignatureFailureReason } from "@rapidmx/react-shared/crypto/messageSecurity.js";
47
+ import { extractAddresses, type MimeAttachment } from "@rapidmx/react-shared/crypto/mime.js";
48
+ import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
49
+ import { isLikelyMailingList } from "@rapidmx/react-shared/crypto/composeSecurity.js";
50
+ import { SenderKeyState, clearPinnedSignerCache, getPinnedSignerFingerprints, getSignerKeyState } from "./pinnedSigners.js";
51
+ import { currentVerificationSeal, getVaultGeneration, sendVerificationSeal } from "./verificationSeals.js";
52
+ import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
53
+ import KeyChangeReview from "../contacts/KeyChangeReview.js";
54
+ import { KEY_CHANGE_STALE_MESSAGE, sameFingerprint } from "../contacts/contactKeys.js";
55
+ import { ComposeLateInput, prefetchComposeWindow, useCompose } from "./compose/ComposeContext.js";
56
+ import { loadOriginalMessage, prefetchOriginalMessage } from "./compose/quotedBody.js";
57
+ import { formatRecipient } from "./compose/recipients.js";
58
+ import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
59
+ import { RecipientLine } from "./MailAddress.js";
60
+ import { useMailShell } from "./layout/MailShell.js";
61
+ import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
62
+ import { SHORTCUTS, ShortcutDef } from "../../keyboard/keymap.js";
63
+ import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
64
+ import { useShortcut } from "../../keyboard/useShortcut.js";
65
+ import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
66
+ import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
67
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
68
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
69
+ import LabelMenuButton from "./labelMenu.js";
70
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
71
+
72
+ /** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
73
+ * plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
74
+ * read as a muted variant of "verified" (a failed signature is a stronger negative than no signature at
75
+ * all), and "Unprotected" MUST NOT read as an error - the class pairs below are chosen so those two
76
+ * never share styling with each other or with the verified states. */
77
+ const SECURITY_INDICATOR: Record<MessageSecurityResult["state"], { label: string; className: string }> = {
78
+ unprotected: { label: "Unprotected", className: "bg-surface-alt text-text-muted" },
79
+ encrypted: { label: "Encrypted", className: "bg-primary/10 text-primary-dark" },
80
+ signed_verified: { label: "Signed & verified", className: "bg-success/10 text-success" },
81
+ encrypted_verified: { label: "Encrypted & verified", className: "bg-success/10 text-success" },
82
+ // A valid signature from a certificate the reader hasn't pinned - anyone can mint a certificate naming the
83
+ // sender, so this must never look like the verified states.
84
+ signed_unverified_signer: { label: "Signed - signer not verified", className: "bg-warning/15 text-text" },
85
+ encrypted_unverified_signer: { label: "Encrypted - signer not verified", className: "bg-warning/15 text-text" },
86
+ signature_failed: { label: "Signature failed", className: "bg-danger-bg text-danger" },
87
+ // Verified when first opened (a verification seal), but not live: muted, never the green verified pill. A signer key
88
+ // later reported compromised switches it to `LATER_COMPROMISED_INDICATOR_CLASS`.
89
+ verified_at_first_open: { label: "Verified when first opened", className: "bg-surface-alt text-text-muted" },
90
+ };
91
+
92
+ /** The amber `verified_at_first_open` badge for a signer key since reported compromised. */
93
+ const LATER_COMPROMISED_INDICATOR_CLASS = "bg-warning/15 text-text";
94
+
95
+ /** Whether a result is (or, for `verified_at_first_open`, was live) a `signer_key_changed` failure. */
96
+ function signerKeyChanged(result: MessageSecurityResult): boolean {
97
+ return result.signatureFailureReason === "signer_key_changed" || result.liveSignatureFailureReason === "signer_key_changed";
98
+ }
99
+
100
+ /** The detail line under a `verified_at_first_open` badge. */
101
+ export function verifiedAtFirstOpenMessage(result: MessageSecurityResult): string {
102
+ const date = new Date(result.verifiedAt!).toLocaleDateString();
103
+ if (result.laterCompromised) {
104
+ return `This signature was verified on ${date}, but the sender's key was later reported compromised; treat this message with caution.`;
105
+ }
106
+ if (result.liveSignatureFailureReason === "signer_key_changed") {
107
+ return `This signature was verified on ${date}. The sender has since started signing with a different key.`;
108
+ }
109
+ 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.`;
110
+ }
111
+
112
+ const VERIFIED_STATES = new Set<MessageSecurityResult["state"]>(["signed_verified", "encrypted_verified"]);
113
+ const UNVERIFIED_SIGNER_STATES = new Set<MessageSecurityResult["state"]>(["signed_unverified_signer", "encrypted_unverified_signer"]);
114
+
115
+ /** An `@`, or a look-alike a reader would take for one (fullwidth, small) - restapi's own `AT_SIGN_LIKE`. */
116
+ const AT_SIGN_LIKE = /[@\uFF20\uFE6B]/;
117
+
118
+ export interface SenderNameCheck {
119
+ /** The display name contains an `@` or a look-alike - it reads as (part of) an address. */
120
+ looksLikeAddress: boolean;
121
+ /** It shows an address-shaped token that isn't `actualAddress` - e.g. `"ceo@corp.com" <x@corp-pay.com>`. */
122
+ misleading: boolean;
123
+ }
124
+
125
+ /** Whether a sender's display name poses as an address, and whether that address differs from the one actually sent
126
+ * from. Compatibility forms are folded first (NFKC turns the fullwidth and small @ into `@`), so a look-alike can't
127
+ * hide the token. */
128
+ export function checkSenderName(displayName: string | undefined, actualAddress: string): SenderNameCheck {
129
+ if (!displayName || !AT_SIGN_LIKE.test(displayName)) {
130
+ return { looksLikeAddress: false, misleading: false };
131
+ }
132
+ const shown = /[^\s<>"'(),;:]+@[^\s<>"'(),;:]+/.exec(displayName.normalize("NFKC"))?.[0];
133
+ return { looksLikeAddress: true, misleading: shown !== undefined && shown.toLowerCase() !== actualAddress.toLowerCase() };
134
+ }
135
+
136
+ export const TRUST_SIGNER_CONFLICT_MESSAGE =
137
+ "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.";
138
+ export const TRUST_SIGNER_INVALID_MESSAGE = "This certificate can't be trusted for this sender.";
139
+ export const TRUST_SIGNER_FORBIDDEN_MESSAGE = "You don't have permission to trust signers for this mailbox.";
140
+ export const TRUST_SIGNER_GENERIC_MESSAGE = "Couldn't trust this signer. Try again.";
141
+ export const KEPT_CURRENT_SIGNING_KEY_MESSAGE = "You kept the current signing key for this sender. This message stays unverified.";
142
+
143
+ /** The error text for a failed `trustSigner()`. */
144
+ export function trustSignerErrorMessage(err: unknown): string {
145
+ if (err instanceof SignerKeyConflictError) {
146
+ return TRUST_SIGNER_CONFLICT_MESSAGE;
147
+ }
148
+ if (err instanceof ApiRequestError && err.status === 400) {
149
+ return TRUST_SIGNER_INVALID_MESSAGE;
150
+ }
151
+ if (err instanceof ApiRequestError && err.status === 403) {
152
+ return TRUST_SIGNER_FORBIDDEN_MESSAGE;
153
+ }
154
+ return TRUST_SIGNER_GENERIC_MESSAGE;
155
+ }
156
+
157
+ /** Groups a fingerprint into 4-character blocks (`AB12 CD34 ...`), ignoring any separators it came with, so it can be
158
+ * read out and compared with the sender over another channel. */
159
+ export function formatFingerprint(fingerprint: string): string {
160
+ const compact = fingerprint.replace(/[\s:]/g, "").toUpperCase();
161
+ return compact.match(/.{1,4}/g)?.join(" ") ?? compact;
162
+ }
163
+
164
+ /** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
165
+ * opaque download (`application/octet-stream`), never rendered in this origin. */
166
+ function downloadMimeAttachment(attachment: MimeAttachment): void {
167
+ // `decode()` is `undefined` only for invalid base64 - an empty file is the honest result then.
168
+ const bytes = attachment.decode() ?? new Uint8Array(0);
169
+ const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: "application/octet-stream" }));
170
+ const a = document.createElement("a");
171
+ a.href = url;
172
+ a.download = attachment.filename ?? "attachment";
173
+ document.body.appendChild(a);
174
+ a.click();
175
+ a.remove();
176
+ // Some browsers are still reading the blob after `click()` returns.
177
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
178
+ }
179
+
180
+ /**
181
+ * One of the reading pane's own actions - Reply, Reply All, Forward, Archive, Move to Other - as an icon
182
+ * button. The action's name is its `aria-label` *and* its tooltip, so it has the same accessible name a
183
+ * labelled button had, and is never drawn beside the icon at any width: the row is icon-only everywhere,
184
+ * the way Outlook's own reading-pane command bar is, so it reads the same in the ~396px pane beside the
185
+ * message list and in a maximised window rather than changing shape at a breakpoint.
186
+ *
187
+ * A plain `<button>` rather than react-shared's `Button`, whose padding and minimum width are sized for a
188
+ * text label. Ordinary DOM order, so the keyboard reaches these in the order they are read.
189
+ */
190
+ function IconAction({
191
+ icon,
192
+ label,
193
+ onClick,
194
+ disabled,
195
+ busy,
196
+ onPrefetch,
197
+ shortcut,
198
+ }: {
199
+ icon: React.ReactNode;
200
+ label: string;
201
+ onClick: () => void;
202
+ disabled?: boolean;
203
+ busy?: boolean;
204
+ /** Called when the pointer or keyboard reaches the button - the moment to start fetching what a click will need. */
205
+ onPrefetch?: () => void;
206
+ /** The keyboard shortcut that does what this button does, when it is registered right now: named in the tooltip (`Reply (Ctrl+R)`) and
207
+ * in `aria-keyshortcuts` - the accessible name stays the bare label. */
208
+ shortcut?: ShortcutDef;
209
+ }) {
210
+ const env = useKeyEnvironment();
211
+ return (
212
+ <button
213
+ type="button"
214
+ aria-label={label}
215
+ title={shortcut ? withHint(label, shortcut, env) : label}
216
+ aria-keyshortcuts={shortcut ? ariaKeyShortcuts(shortcut, env) : undefined}
217
+ aria-busy={busy || undefined}
218
+ disabled={disabled}
219
+ onClick={onClick}
220
+ onPointerEnter={onPrefetch}
221
+ onFocus={onPrefetch}
222
+ className="inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent"
223
+ >
224
+ {icon}
225
+ </button>
226
+ );
227
+ }
228
+
229
+ function SecurityIndicator({ security }: { security: MessageSecurityResult }) {
230
+ const { label, className } = SECURITY_INDICATOR[security.state];
231
+ if (security.state !== "verified_at_first_open") {
232
+ return <span className={`text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`}>{label}</span>;
233
+ }
234
+ return (
235
+ <span
236
+ className={`inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${
237
+ security.laterCompromised ? LATER_COMPROMISED_INDICATOR_CLASS : className
238
+ }`}
239
+ >
240
+ {security.laterCompromised ? (
241
+ <HiOutlineExclamationTriangle size={12} aria-hidden="true" />
242
+ ) : (
243
+ <HiOutlineCheck size={12} aria-hidden="true" />
244
+ )}
245
+ {label}
246
+ </span>
247
+ );
248
+ }
249
+
250
+ /** User-facing explanation for each `signatureFailureReason` - shown alongside the "Signature failed"
251
+ * badge so an unverifiable (e.g. foreign/unparseable) signed message reads as "not verified, and here's
252
+ * why" rather than as a bare error. The body itself stays visible either way. */
253
+ const SIGNATURE_FAILURE_MESSAGE: Record<SignatureFailureReason, string> = {
254
+ invalid_signature:
255
+ "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.",
256
+ untrusted_signer: "This message was signed with a certificate that doesn't match the sender's known key. Treat it as unverified.",
257
+ // Never shown: the dedicated "signing key changed" notice replaces it. Present so every reason has copy.
258
+ signer_key_changed: "This message was signed with a different key than the one you trust for this sender. Treat it as unverified.",
259
+ signer_identity_mismatch:
260
+ "This message's signing certificate doesn't belong to the sender shown in From. Treat it as unverified.",
261
+ header_mismatch:
262
+ "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.",
263
+ };
264
+ const GENERIC_SIGNATURE_FAILURE_MESSAGE = "This message's digital signature couldn't be verified. Treat it as unverified.";
265
+
266
+ /** Fallback when the raw MIME for an encrypted message can't even be fetched/evaluated - an encrypted
267
+ * message must never be mislabeled "Unprotected" just because loading failed. */
268
+ const ENCRYPTED_LOAD_ERROR = "Couldn't load this message's encrypted content.";
269
+
270
+ /** Content Security Policy prepended to every client-rendered (decrypted/verified) body's `srcDoc` - the
271
+ * backstop behind the sanitizer below: nothing in the document may load any remote resource at all, only
272
+ * inline styles and `data:`/`cid:` images. */
273
+ const BODY_CSP_META =
274
+ "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; img-src data: cid:; style-src 'unsafe-inline'\">";
275
+
276
+ /** Sanitizes a client-rendered body for `srcDoc` (DOMPurify plus every remote resource reference stripped - see
277
+ * react-shared's `messageBodySanitizer.ts`), with the CSP meta as its very first element. */
278
+ export function buildSecureSrcDoc(html: string): string {
279
+ return BODY_CSP_META + sanitizeMessageBodyHtml(html);
280
+ }
281
+
282
+ export interface MessageDetailPaneProps {
283
+ message: Message | null;
284
+ attachments: Attachment[];
285
+ /** Present only on the mobile detail route — renders a "back to messages" link above the header. Absent
286
+ * on the desktop reading pane, which never navigates away (selecting a different message just swaps
287
+ * `message` in place). */
288
+ backHref?: string;
289
+ /** Whether `message` currently lives in Sent Items — the only folder recall is offered from, matching
290
+ * Outlook's own restriction (and `BaseMessageRoute.recall()`'s own server-side check). Each caller
291
+ * computes this from its own already-loaded folder list rather than this component fetching folders
292
+ * itself. */
293
+ isSentItems?: boolean;
294
+ /** Called with the server's updated copy (carrying `recallRequestedAt`) after a successful recall, so
295
+ * the caller can patch its own in-memory message/list state — mirrors `mailDetailHooks.ts`'s
296
+ * `useMarkMessageRead`'s identical `onUpdated` callback. */
297
+ onRecalled?: (updated: Message) => void;
298
+ /** Whether `message` currently lives in Outbox — the only folder a scheduled send can be canceled
299
+ * from. Each caller computes this the same way it computes `isSentItems`. */
300
+ isOutbox?: boolean;
301
+ /** The mailbox's Drafts folder uid — required when `isOutbox` is `true`, since canceling a
302
+ * scheduled send moves the message back into Drafts (see `cancelScheduledSend()`'s own doc comment
303
+ * on why clearing `scheduledSendTime` alone doesn't do that). */
304
+ draftsFolderUid?: string;
305
+ /** Called with the server's updated copy (now back in Drafts, `scheduledSendTime` cleared) after
306
+ * successfully canceling a scheduled send. */
307
+ onScheduledSendCanceled?: (updated: Message) => void;
308
+ /** Every folder of this message's own mailbox, for the Move to prompt's destination list. Absent or
309
+ * empty simply hides the control — there is nowhere to move to. Each caller already resolves this to
310
+ * work out `isSentItems`/`isOutbox`/`draftsFolderUid`, so nothing new is fetched for it. */
311
+ folders?: Folder[];
312
+ /** Called with the server's updated copy (now in the chosen folder) after a successful Move to. A move
313
+ * takes the message out of whichever folder is being listed, so callers remove it from their list
314
+ * rather than patching it — unlike `onClassified`, which this replaces. */
315
+ onMoved?: (updated: Message) => void;
316
+ /** A folder created from the Move to prompt, so the caller's own list and the folder sidebar pick it up
317
+ * without a page load. */
318
+ onFolderCreated?: (folder: Folder) => void;
319
+ /** Called with the server's updated copy after approving/declining a pending delivery/read receipt —
320
+ * mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`):
321
+ * `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are only ever
322
+ * `true` on a real delivered copy, so the banner below is self-gating. */
323
+ onReceiptHandled?: (updated: Message) => void;
324
+ /** Called with the server's updated copy (now filed under the mailbox's Archive folder) after a
325
+ * successful archive — mirrors `onRecalled`'s identical shape. Archiving itself is offered for any
326
+ * message except one currently in Drafts or Outbox (mirrors `BaseMessageRoute.archive()`'s own
327
+ * server-side 400 guard); Drafts is detected via `draftsFolderUid` (already passed by every caller
328
+ * for the Outbox-cancel flow) rather than a new prop. */
329
+ onArchived?: (updated: Message) => void;
330
+ /** Every label defined in this message's mailbox, for the label-assignment popover below — each
331
+ * caller fetches its own mailbox's labels the same way it already resolves `draftsFolderUid`
332
+ * (`listLabels()`). Absent/empty simply hides the Labels control - there's nothing to assign. */
333
+ labels?: Label[];
334
+ /** Called with the server's updated copy (carrying the new `labelUids`) after successfully toggling
335
+ * a label — always patches in place, never removes from a caller's list (unlike `onArchived`):
336
+ * changing labels never moves a message between folders. */
337
+ onLabelsChanged?: (updated: Message) => void;
338
+ /** Handed a label created from the Labels menu, for the caller to add to `labels`. Without it the menu
339
+ * offers no "New label" row, only the link to where labels are managed. */
340
+ onLabelCreated?: (label: Label) => void;
341
+ /** Rendered as one message of a thread (`ConversationThreadPane`) rather than as the pane itself: the
342
+ * subject becomes a heading under the thread's own, since a document has one `h1` and the thread's is
343
+ * the conversation. Everything else - the badges, the actions, the body - is identical. */
344
+ inThread?: boolean;
345
+ /**
346
+ * Registers this pane's keyboard shortcuts - Reply, Reply all, Forward, Archive, Move to - so they act on this message. Only the message
347
+ * the keyboard is acting on should set it: the one pane beside the list, or the opened message of a thread (each expanded message of a
348
+ * thread is a pane, and two of them must not both claim Ctrl+R). Also puts the shortcut in those buttons' tooltips.
349
+ */
350
+ shortcuts?: boolean;
351
+ }
352
+
353
+ function formatBytes(bytes: number): string {
354
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
355
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
356
+ return `${bytes} B`;
357
+ }
358
+
359
+ /**
360
+ * A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
361
+ * Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
362
+ * and the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
363
+ * a message row) — see each call site for how `message`/`attachments`/`isSentItems` are sourced. The
364
+ * desktop pane shows whichever message the list opened, including one opened from a conversation row.
365
+ */
366
+ export default function MessageDetailPane(props: MessageDetailPaneProps) {
367
+ if (!props.message) {
368
+ return <p className="p-8 text-sm text-text-muted">Select a message to read it.</p>;
369
+ }
370
+ // Keyed by uid so every piece of per-message state below (security result, open modals, in-flight
371
+ // flags, errors) resets cleanly when a caller swaps `message` in place rather than remounting.
372
+ return <MessageDetailContent key={props.message.uid} {...props} message={props.message} />;
373
+ }
374
+
375
+ /** Whether a message's raw MIME has to be fetched to evaluate its security state. `encrypted` is the
376
+ * server's own classification; there is no server-side "signed" flag, but every S/MIME-signed shape
377
+ * (detached `multipart/signed`'s `smime.p7s`, opaque-signed `smime.p7m`) is stored with its CMS part
378
+ * surfaced as an attachment - so a message with neither flag can't carry a signature to verify, and
379
+ * fetching its full raw source (attachments included) would be pure waste. */
380
+ function needsRawSecurityEvaluation(message: Message): boolean {
381
+ return !!message.encrypted || message.hasAttachments;
382
+ }
383
+
384
+ function MessageDetailContent({
385
+ message: messageProp,
386
+ attachments,
387
+ backHref,
388
+ isSentItems,
389
+ onRecalled,
390
+ isOutbox,
391
+ draftsFolderUid,
392
+ onScheduledSendCanceled,
393
+ folders,
394
+ onMoved,
395
+ onFolderCreated,
396
+ onReceiptHandled,
397
+ onArchived,
398
+ labels,
399
+ onLabelsChanged,
400
+ onLabelCreated,
401
+ inThread,
402
+ shortcuts,
403
+ }: MessageDetailPaneProps & { message: Message }) {
404
+ // A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
405
+ // `reloadMessage()`. A prop copy newer by `version` wins; an equal-version reload is kept, since claiming a send
406
+ // lease need not bump `version`.
407
+ const [reloaded, setReloaded] = useState<Message | null>(null);
408
+ const message = reloaded && reloaded.version >= messageProp.version ? reloaded : messageProp;
409
+ // A reload that finds the message already moved on (sent, or moved to Drafts elsewhere) leaves Outbox.
410
+ const inOutbox = !!isOutbox && message.folderUid === messageProp.folderUid;
411
+ const { openCompose } = useCompose();
412
+ const [confirming, setConfirming] = useState(false);
413
+ const [recalling, setRecalling] = useState(false);
414
+ const [canceling, setCanceling] = useState(false);
415
+ const [archiving, setArchiving] = useState(false);
416
+ const [archiveError, setArchiveError] = useState<string | null>(null);
417
+ const [savingLabels, setSavingLabels] = useState(false);
418
+ const [labelsError, setLabelsError] = useState<string | null>(null);
419
+ const [error, setError] = useState<string | null>(null);
420
+ // Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
421
+ // pane rather than inside a confirmation modal — the two flows never need to share one message.
422
+ const [cancelError, setCancelError] = useState<string | null>(null);
423
+ const [movePrompt, setMovePrompt] = useState(false);
424
+ // Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
425
+ // `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
426
+ // wouldn't distinguish which row's buttons should show a loading state.
427
+ const [receiptBusy, setReceiptBusy] = useState<ReceiptType | null>(null);
428
+ const [receiptError, setReceiptError] = useState<string | null>(null);
429
+ const [security, setSecurity] = useState<MessageSecurityResult | null>(null);
430
+ // Whether the pinned-signer lookup for the sender *succeeded* and found no signing keys - the only case "Trust this
431
+ // signer" is offered in. A failed lookup could be hiding an existing pin, and trusting never replaces one.
432
+ const [senderUnpinned, setSenderUnpinned] = useState(false);
433
+ const [trustConfirmOpen, setTrustConfirmOpen] = useState(false);
434
+ const [trusting, setTrusting] = useState(false);
435
+ const [trustError, setTrustError] = useState<string | null>(null);
436
+ // The sender's stored signing-key state, loaded for a `signer_key_changed` result (the comparison) and for an
437
+ // unverified signer with no pins (a recorded conflict sends the reader to the contact instead of offering trust).
438
+ // `undefined` when not needed or it couldn't be loaded.
439
+ const [senderKeyState, setSenderKeyState] = useState<SenderKeyState | undefined>(undefined);
440
+ // Whether the reader may update this mailbox (resolving a key change needs it) - `undefined` when unknown.
441
+ const [canUpdateMailbox, setCanUpdateMailbox] = useState<boolean | undefined>(undefined);
442
+ // "Keys changed while you were looking" after a 409, or the outcome of keeping the current key. Survives the
443
+ // re-evaluation that follows.
444
+ const [keyChangeNotice, setKeyChangeNotice] = useState<string | null>(null);
445
+ // "Now", for deciding whether a scheduled send's lease is still live - advanced when the lease runs out.
446
+ const [nowMs, setNowMs] = useState(() => Date.now());
447
+ const { mailboxes, trackMessageChange } = useMailShell();
448
+ const { requestUnlock } = useUnlockPrompt();
449
+ // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
450
+ // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
451
+ // state; see keySession.ts's own doc comment).
452
+ const [unlockRefresh, setUnlockRefresh] = useState(0);
453
+
454
+ // The newest copy of this message known to the Labels popover - normally just the `message` prop,
455
+ // but a successful label toggle's server response is held here too, so a follow-up toggle always
456
+ // builds on the latest `labelUids`/`version` even before (or without) the caller patching its own
457
+ // state via `onLabelsChanged`. A prop copy at least as new (by `version`) always wins.
458
+ const [labelsMessage, setLabelsMessage] = useState<Message>(message);
459
+ const currentLabelsMessage = message.version >= labelsMessage.version ? message : labelsMessage;
460
+ const latestLabelsMessageRef = useRef<Message>(message);
461
+ const rawEvaluationNeeded = needsRawSecurityEvaluation(message);
462
+ // Every address the viewing mailbox receives at (primary first), as one string so the effect below only
463
+ // re-runs when they actually change.
464
+ const readerMailbox = mailboxes.find((mb) => mb.uid === message.mailboxUid);
465
+ const readerAddressesKey = readerMailbox ? [readerMailbox.primarySmtpAddress, ...(readerMailbox.aliasAddresses ?? [])].join(" ") : "";
466
+
467
+ // Evaluates a message's security state from its raw MIME - only for a message that can actually be
468
+ // encrypted or signed (see `needsRawSecurityEvaluation()`); anything else is "Unprotected" without a
469
+ // fetch. A detached `multipart/signed` message needs its raw MIME read (a sanitized HTML body never
470
+ // carries the signature part) to tell "Signed & verified" apart from plain "Unprotected". A
471
+ // fetch/parse failure degrades to "Unprotected" (or, for a server-flagged encrypted message, to
472
+ // "Encrypted" with an explanation) rather than hiding the message.
473
+ //
474
+ // `getMessageRawContent()` takes no `AbortSignal`, so a superseded fetch can't be aborted - its
475
+ // result is ignored via `cancelled` instead (and never handed to `evaluateMessageSecurity()`).
476
+ useEffect(() => {
477
+ if (!rawEvaluationNeeded) {
478
+ setSecurity({ state: "unprotected" });
479
+ return;
480
+ }
481
+ let cancelled = false;
482
+ setSecurity(null);
483
+ setSenderUnpinned(false);
484
+ setSenderKeyState(undefined);
485
+ // The sender's trusted signing keys: what key discovery pinned on this mailbox's contacts, plus this
486
+ // mailbox's own signing keys when it sent the message itself. A failed lookup means no pins, which can only
487
+ // ever make a signature "signer not verified", never verified.
488
+ const senderAddress = message.from.address;
489
+ const pinsPromise = getPinnedSignerFingerprints(message.mailboxUid, senderAddress).then(
490
+ (fingerprints) => ({ loaded: true, fingerprints }),
491
+ () => ({ loaded: false, fingerprints: [] as string[] }),
492
+ );
493
+ // Verification seals (see `verificationSeals.ts`) need unlocked keys and the vault's current master key
494
+ // generation; without either the message is evaluated exactly as before, with no seal. The sender's key records
495
+ // are loaded alongside, so a seal can say when the signer key was later revoked as compromised.
496
+ let keyStatePromise: Promise<SenderKeyState | undefined> | undefined;
497
+ const loadKeyState = () => (keyStatePromise ??= getSignerKeyState(message.mailboxUid, senderAddress).catch(() => undefined));
498
+ const sealContextPromise = getUnlockedKeys(message.mailboxUid)
499
+ ? getVaultGeneration(message.mailboxUid).then(async (generation) =>
500
+ generation === undefined ? undefined : { generation, keyState: await loadKeyState() },
501
+ )
502
+ : undefined;
503
+ getMessageRawContent(message.uid)
504
+ .then(async (rawMime) => {
505
+ if (cancelled) {
506
+ return;
507
+ }
508
+ // The S/MIME code (PKI.js and the ASN.1/X.509 libraries) loads here, only for a message that is signed or
509
+ // encrypted - it is over half a megabyte, and the pane opens plain messages without it.
510
+ const messageSecurity = await import("@rapidmx/react-shared/crypto/messageSecurity.js");
511
+ const [primaryAddress, ...aliasAddresses] = readerAddressesKey ? readerAddressesKey.split(" ") : [];
512
+ const ownAddresses = readerAddressesKey.toLowerCase().split(" ");
513
+ const ownKeys = ownAddresses.includes(senderAddress.toLowerCase()) ? (readerMailbox!.keys ?? []) : [];
514
+ const ownPins = signingKeyFingerprints(ownKeys);
515
+ const [contactPins, sealContext] = await Promise.all([pinsPromise, sealContextPromise]);
516
+ const pins = [...new Set([...contactPins.fingerprints, ...ownPins])];
517
+ const pinned = pins.length > 0 ? pins : undefined;
518
+ // The raw content is a byte string - only ever handed to evaluateMessageSecurity(), never shown. Keys
519
+ // are read after the pin lookup's await, so a lock that happened meanwhile is honored.
520
+ const unlocked = getUnlockedKeys(message.mailboxUid);
521
+ let result: MessageSecurityResult;
522
+ if (sealContext && unlocked) {
523
+ const signerKeys = [
524
+ ...(sealContext.keyState?.pinned ?? []),
525
+ ...(sealContext.keyState?.previous ?? []),
526
+ ...ownKeys,
527
+ ];
528
+ result = await messageSecurity.evaluateMessageSecurityWithSeal(rawMime, unlocked, pinned, primaryAddress, {
529
+ mailboxUid: message.mailboxUid,
530
+ messageUid: message.uid,
531
+ ...currentVerificationSeal(message),
532
+ masterKeyGeneration: sealContext.generation,
533
+ signerKeys,
534
+ }).catch(() =>
535
+ // Only a lock while sealing throws: evaluate as locked (the lock itself re-evaluates too).
536
+ messageSecurity.evaluateMessageSecurity(rawMime, undefined, pinned, primaryAddress),
537
+ );
538
+ if (result.sealToWrite) {
539
+ // Best effort and in the background: never awaited, never shown.
540
+ void sendVerificationSeal(message.uid, result.sealToWrite);
541
+ }
542
+ } else {
543
+ result = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, primaryAddress);
544
+ }
545
+ // Only one reader address can be checked per evaluation: a message sent to one of this mailbox's
546
+ // aliases isn't "not addressed to you", so each alias is tried before saying so.
547
+ for (const alias of aliasAddresses) {
548
+ if (!result.notAddressedToReader) {
549
+ break;
550
+ }
551
+ const viaAlias = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
552
+ result = { ...result, notAddressedToReader: viaAlias.notAddressedToReader };
553
+ }
554
+ const unpinned = contactPins.loaded && pins.length === 0;
555
+ const keyChanged = signerKeyChanged(result);
556
+ const [keyState, access] = await Promise.all([
557
+ keyChanged || (unpinned && UNVERIFIED_SIGNER_STATES.has(result.state)) ? loadKeyState() : undefined,
558
+ keyChanged ? getMyMailboxAccess(message.mailboxUid).then((a) => a.canUpdate, () => undefined) : undefined,
559
+ ]);
560
+ if (!cancelled) {
561
+ setSecurity(result);
562
+ setSenderUnpinned(unpinned);
563
+ setSenderKeyState(keyState);
564
+ setCanUpdateMailbox(access);
565
+ }
566
+ })
567
+ .catch(() => {
568
+ if (!cancelled) {
569
+ setSecurity(
570
+ message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" },
571
+ );
572
+ }
573
+ });
574
+ return () => {
575
+ cancelled = true;
576
+ };
577
+ }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey]);
578
+
579
+ // Decrypted plaintext must not outlive the key session that produced it: the moment this mailbox's
580
+ // keys are destroyed (logout, idle timeout, explicit lock), drop the recovered html/text and
581
+ // re-evaluate - an encrypted message then falls back to its "Unlock to view" state.
582
+ useEffect(
583
+ () =>
584
+ subscribeKeySession((event) => {
585
+ if (event.mailboxUid === message.mailboxUid && event.state === "locked") {
586
+ setSecurity(null);
587
+ setUnlockRefresh((n) => n + 1);
588
+ }
589
+ }),
590
+ [message.mailboxUid],
591
+ );
592
+
593
+ // While restapi's ScheduledSendJob holds a send lease the message is being relayed: cancelling or moving it would be
594
+ // refused (409/403), so the Outbox controls are replaced by "Sending...". When the lease runs out the message is
595
+ // re-read - it has either been sent (and left Outbox) or been released back to its schedule.
596
+ const leaseExpiresMs = message.scheduledSendLeaseExpiresAt ? Date.parse(message.scheduledSendLeaseExpiresAt) : NaN;
597
+ const sendInProgress = inOutbox && leaseExpiresMs > nowMs;
598
+ useEffect(() => {
599
+ if (!sendInProgress) {
600
+ return;
601
+ }
602
+ const timer = setTimeout(() => {
603
+ setNowMs(Date.now());
604
+ void reloadMessage();
605
+ }, leaseExpiresMs - nowMs + 1);
606
+ return () => clearTimeout(timer);
607
+ }, [sendInProgress, leaseExpiresMs, nowMs]);
608
+
609
+ async function reloadMessage() {
610
+ try {
611
+ setReloaded(await getMessage(message.uid));
612
+ setNowMs(Date.now());
613
+ } catch {
614
+ // Keep showing what we have - the next refused action or lease expiry tries again.
615
+ }
616
+ }
617
+
618
+ // Offered only when this device genuinely has no unlocked session for this message's mailbox at all
619
+ // (as opposed to being unlocked but still unable to decrypt - a wrong/since-rotated key, which
620
+ // re-unlocking the same session can't fix) - see `evaluateMessageSecurity()`'s own doc comment on why
621
+ // `decryptError` alone can't distinguish those two cases.
622
+ async function handleUnlockToView() {
623
+ const mailboxUid = message.mailboxUid;
624
+ const mailboxKeys = mailboxes.find((mb) => mb.uid === mailboxUid)?.keys ?? [];
625
+ try {
626
+ await requestUnlock(mailboxUid, mailboxKeys);
627
+ setUnlockRefresh((n) => n + 1);
628
+ } catch {
629
+ // User dismissed the unlock dialog - security state stays exactly as it was.
630
+ }
631
+ }
632
+
633
+ // Only ever invoked from the "Trust this signer" dialog, which only opens for an unverified-signer result carrying
634
+ // `signerCertificate`. The pin goes on the contact in the mailbox the message belongs to; the evaluation is then
635
+ // re-run against a fresh pinned-signer lookup, so the badge turns verified.
636
+ async function handleTrustSigner(address: string, certificate: string) {
637
+ setTrusting(true);
638
+ setTrustError(null);
639
+ try {
640
+ await trustSigner(message.mailboxUid, { address, certificate });
641
+ clearPinnedSignerCache();
642
+ setTrustConfirmOpen(false);
643
+ setUnlockRefresh((n) => n + 1);
644
+ } catch (err) {
645
+ setTrustError(trustSignerErrorMessage(err));
646
+ } finally {
647
+ setTrusting(false);
648
+ }
649
+ }
650
+
651
+ // After a key change was accepted or kept (or the pinned key moved meanwhile): the pinned signers are re-read and
652
+ // the message re-evaluated, so an accepted key turns the badge verified.
653
+ function refreshAfterKeyChange(notice: string | null) {
654
+ setKeyChangeNotice(notice);
655
+ clearPinnedSignerCache();
656
+ setUnlockRefresh((n) => n + 1);
657
+ }
658
+
659
+ // Reply, Reply All and Forward first load the full body to quote (see `loadQuotedBody()`), so the buttons are
660
+ // disabled meanwhile - a second click would open a second window. An encrypted original keeps its reply
661
+ // encrypted, since the quote may carry its decrypted content.
662
+ const [preparingCompose, setPreparingCompose] = useState(false);
663
+
664
+ /** The replying mailbox's own addresses (primary and aliases), which a reply never goes to - from the mail shell's
665
+ * mailbox list, else fetched; none when neither is available. */
666
+ async function ownAddresses(): Promise<string[]> {
667
+ const mailbox = readerMailbox ?? (await getMailbox(message.mailboxUid).catch(() => undefined));
668
+ return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
669
+ }
670
+
671
+ /** Starts fetching what Reply/Reply All/Forward will need (the compose window's code, the original's body) as the
672
+ * pointer or keyboard reaches one of their buttons, so the click itself finds it already on its way or here. */
673
+ function prefetchReply() {
674
+ prefetchComposeWindow();
675
+ prefetchOriginalMessage(message);
676
+ }
677
+
678
+ /**
679
+ * Opens the compose window at once from what is already in memory - the message's own subject and sender, and the
680
+ * mailbox it was read in - and fetches what needs the network meanwhile: the original's body to quote, and for Reply All
681
+ * the recipients its headers name. Both reach the window through `OpenComposeInput.pending` when they arrive, so the
682
+ * window is on screen on the click's own frame rather than after two or three sequential requests.
683
+ */
684
+ async function handleReplyOrForward(kind: "reply" | "replyAll" | "forward") {
685
+ setPreparingCompose(true);
686
+ try {
687
+ const encrypt = !!message.encrypted;
688
+ // What makes the new message part of this one's thread rather than a conversation of its own:
689
+ // the server composes the MIME from the recipients, subject and HTML alone, so nothing else
690
+ // recovers what is being replied to. A forward carries it for the same reason - it continues
691
+ // the thread it came from, which is where its recipient will file the reply to it.
692
+ const threading = buildReplyThreading(message);
693
+ const format = (list: Recipient[]) => list.map((r) => formatRecipient(r)).join(", ");
694
+ // Known without a request when the shell has already listed the mailbox; otherwise fetched with the body, and
695
+ // the window's recipients are corrected by `late` below if that changes who they are.
696
+ const knownOwn = readerAddressesKey ? readerAddressesKey.split(" ") : undefined;
697
+ // Never rejects: loadOriginalMessage() and ownAddresses() don't, and the rest is pure.
698
+ const late = (async (): Promise<ComposeLateInput> => {
699
+ const [original, own] = await Promise.all([
700
+ loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
701
+ kind === "forward" ? [] : (knownOwn ?? ownAddresses()),
702
+ ]);
703
+ if (kind === "forward") {
704
+ return { quotedHtml: buildForwardQuote(message, original.body) };
705
+ }
706
+ // The message's own recipients are only the envelope recipient of a delivered message, so Reply All
707
+ // answers whoever its headers actually name, with anything the record holds that they don't.
708
+ const known = new Set((original.recipients ?? []).map((r) => r.address.trim().toLowerCase()));
709
+ const recipients = buildReplyRecipients(
710
+ { ...message, recipients: [...(original.recipients ?? []), ...message.recipients.filter((r) => !known.has(r.address.trim().toLowerCase()))] },
711
+ own,
712
+ kind === "replyAll",
713
+ );
714
+ return { quotedHtml: buildReplyQuote(message, original.body), to: format(recipients.to), cc: format(recipients.cc) };
715
+ })();
716
+ if (kind === "forward") {
717
+ openCompose({
718
+ mailboxUid: message.mailboxUid,
719
+ subject: forwardSubject(message.subject),
720
+ signatureContext: "reply_forward",
721
+ encrypt,
722
+ threading,
723
+ pending: late,
724
+ });
725
+ } else {
726
+ const first = buildReplyRecipients(message, knownOwn ?? [], kind === "replyAll");
727
+ openCompose({
728
+ mailboxUid: message.mailboxUid,
729
+ to: format(first.to),
730
+ cc: first.cc.length > 0 ? format(first.cc) : undefined,
731
+ subject: replySubject(message.subject),
732
+ signatureContext: "reply_forward",
733
+ suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
734
+ encrypt,
735
+ threading,
736
+ pending: late,
737
+ });
738
+ }
739
+ // Held until the quote is in, so a second click can't open a second window for the same message meanwhile.
740
+ await late;
741
+ } finally {
742
+ setPreparingCompose(false);
743
+ }
744
+ }
745
+
746
+ async function handleRecall() {
747
+ // Only ever invoked from the confirmation modal below, which itself only renders once `message`
748
+ // is loaded (the early return above covers the only other state) — the non-null assertion
749
+ // reflects that real invariant, matching this codebase's established pattern for the same class
750
+ // of always-true-in-practice guard.
751
+ setRecalling(true);
752
+ setError(null);
753
+ try {
754
+ const updated = await recallMessage(message.uid);
755
+ setConfirming(false);
756
+ onRecalled?.(updated);
757
+ } catch (err) {
758
+ setError(err instanceof ApiRequestError ? err.message : "Could not recall this message.");
759
+ } finally {
760
+ setRecalling(false);
761
+ }
762
+ }
763
+
764
+ // Only ever invoked from the "Cancel" button below (`isOutbox` and `message.scheduledSendTime` both truthy -
765
+ // `draftsFolderUid` is required whenever `isOutbox` is, see the prop's own doc comment), or from "Move to
766
+ // Drafts" (an Outbox message with no active schedule, which checks `draftsFolderUid` itself), so the non-null
767
+ // assertion reflects a real invariant, matching `handleRecall`'s identical pattern just above.
768
+ async function handleCancelScheduledSend() {
769
+ setCanceling(true);
770
+ setCancelError(null);
771
+ try {
772
+ const updated = await cancelScheduledSend(message, draftsFolderUid!);
773
+ // Keeps a `folder:`-scoped Tier 2 local search from still finding it in its old folder.
774
+ void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
775
+ // Out of Outbox, into Drafts: both folders' badges.
776
+ trackMessageChange(message, updated).settle();
777
+ onScheduledSendCanceled?.(updated);
778
+ } catch (err) {
779
+ const fallback = message.scheduledSendTime ? "Could not cancel this scheduled send." : "Could not move this message to Drafts.";
780
+ setCancelError(err instanceof ApiRequestError ? err.message : fallback);
781
+ // A conflict or refusal usually means the send job claimed (or already relayed) the message meanwhile -
782
+ // re-read it so the pane shows "Sending..." or its new state instead of the stale controls.
783
+ if (err instanceof ApiRequestError && (err.status === 409 || err.status === 403)) {
784
+ void reloadMessage();
785
+ }
786
+ } finally {
787
+ setCanceling(false);
788
+ }
789
+ }
790
+
791
+ // Only ever invoked from the Archive button below, which itself only renders once `message` is
792
+ // loaded and this isn't a Drafts/Outbox message (the button's own guard mirrors
793
+ // `BaseMessageRoute.archive()`'s server-side 400) — same real invariant as `handleRecall` above.
794
+ async function handleArchive() {
795
+ setArchiving(true);
796
+ setArchiveError(null);
797
+ try {
798
+ const updated = await archiveMessage(message.uid);
799
+ void moveLocalEntity(updated.mailboxUid, updated.uid, updated.folderUid);
800
+ trackMessageChange(message, updated).settle();
801
+ onArchived?.(updated);
802
+ } catch (err) {
803
+ setArchiveError(err instanceof ApiRequestError ? err.message : "Could not archive this message.");
804
+ } finally {
805
+ setArchiving(false);
806
+ }
807
+ }
808
+
809
+ // Only ever invoked from the Labels menu below, which itself only renders once `message` is loaded.
810
+ // Several labels are ticked in the menu and saved together here, in one `setMessageLabels()` call:
811
+ // that call replaces the whole `labelUids` list rather than patching one entry, so a menu that saved
812
+ // per tick would send a request (and burn an optimistic-lock `version`) for every single tick.
813
+ //
814
+ // The list is computed from the newest copy known (`latestLabelsMessageRef`, never a stale render
815
+ // closure), so a save that follows another still carries the `version` the server last handed back.
816
+ async function handleApplyLabels(labelUids: string[]) {
817
+ setSavingLabels(true);
818
+ setLabelsError(null);
819
+ try {
820
+ const base = message.version >= latestLabelsMessageRef.current.version ? message : latestLabelsMessageRef.current;
821
+ const updated = await setMessageLabels(base, labelUids);
822
+ latestLabelsMessageRef.current = updated;
823
+ setLabelsMessage(updated);
824
+ onLabelsChanged?.(updated);
825
+ } catch (err) {
826
+ setLabelsError(err instanceof ApiRequestError ? err.message : "Could not update this message's labels.");
827
+ } finally {
828
+ setSavingLabels(false);
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Moves this message into the folder picked in the Move to prompt. Deliberately *not* caught here:
834
+ * `MoveToFolderDialog` shows a failure beside the destination that would retry it, and closes only
835
+ * once the move has actually landed.
836
+ *
837
+ * The move is awaited on its own line rather than written as `onMoved?.(await moveMessage(...))` -
838
+ * an optional call whose callee is absent never evaluates its arguments at all, so a caller that
839
+ * passes no `onMoved` would have had nothing moved while the prompt closed as if it had.
840
+ */
841
+ async function handleMove(folderUid: string) {
842
+ const updated = await moveMessage(message, folderUid);
843
+ // Move to, Delete and Report junk are all this: the source folder's badge goes down, the target's up.
844
+ trackMessageChange(message, updated).settle();
845
+ onMoved?.(updated);
846
+ }
847
+
848
+ // Keyboard shortcuts for the message this pane shows. They call the same handlers as the buttons below, and exist only where the
849
+ // button does (Archive not for Drafts/Outbox, Move to only with folders to move to). A shortcut pressed while its action is already
850
+ // running is consumed and does nothing, exactly as the disabled button would: a browser must not act on Ctrl+R meanwhile.
851
+ const archivable = !inOutbox && message.folderUid !== draftsFolderUid;
852
+ const movable = !!folders && folders.length > 0;
853
+ const keyboard = !!shortcuts;
854
+ useShortcut(SHORTCUTS.mail.reply, () => void (!preparingCompose && handleReplyOrForward("reply")), { enabled: keyboard });
855
+ useShortcut(SHORTCUTS.mail.replyAll, () => void (!preparingCompose && handleReplyOrForward("replyAll")), { enabled: keyboard });
856
+ useShortcut(SHORTCUTS.mail.forward, () => void (!preparingCompose && handleReplyOrForward("forward")), { enabled: keyboard });
857
+ useShortcut(SHORTCUTS.mail.archive, () => void (!archiving && handleArchive()), { enabled: keyboard && archivable });
858
+ useShortcut(SHORTCUTS.mail.move, () => setMovePrompt(true), { enabled: keyboard && movable });
859
+
860
+ // Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
861
+ // is loaded — same real invariant as every other handler above.
862
+ async function handleReceipt(type: ReceiptType, action: "approve" | "decline") {
863
+ setReceiptBusy(type);
864
+ setReceiptError(null);
865
+ try {
866
+ const updated = await (action === "approve" ? approveReceipt : declineReceipt)(message.uid, type);
867
+ onReceiptHandled?.(updated);
868
+ } catch (err) {
869
+ setReceiptError(err instanceof ApiRequestError ? err.message : "Could not handle this receipt request.");
870
+ } finally {
871
+ setReceiptBusy(null);
872
+ }
873
+ }
874
+
875
+ const verified = security !== null && VERIFIED_STATES.has(security.state);
876
+ // A seal proves the signature verified when first opened, and the live check still passed everything but the signer
877
+ // key's status - so the signed Subject and attachments are shown as for a verified message, under its own badge.
878
+ const verifiedOrSealed = verified || security?.state === "verified_at_first_open";
879
+ // Under a verified badge, the Subject shown is the one the signature covers (RFC 9788 protected headers), when
880
+ // the message carries one - the outer Subject is unsigned and anyone relaying the message could change it.
881
+ const protectedSubject = verifiedOrSealed ? security.protectedHeaders?.subject : undefined;
882
+ // Only meaningful for signed-only mail: an encrypted message's outer Subject is deliberately obscured. Not a
883
+ // signature failure (mailing lists legitimately tag subjects), just worth pointing out.
884
+ const subjectDiffers = protectedSubject !== undefined && (security!.state === "signed_verified" || security!.sealedState === "signed_verified") && protectedSubject !== message.subject;
885
+ // Attachments inside the signed/decrypted entity. Under a verified badge only these are listed - the server's
886
+ // attachment records also include parts outside the signature, which the badge doesn't vouch for. For decrypted
887
+ // mail the server only ever saw the encrypted blob, so these are the real attachments.
888
+ // A decrypted message whose signature failed still only has these (the server list is just `smime.p7m`); they're
889
+ // listed with a warning. A signed-only failure never carries recovered attachments.
890
+ const innerAttachments =
891
+ security?.attachments !== undefined &&
892
+ (verifiedOrSealed || security.state === "encrypted" || security.state === "encrypted_unverified_signer" || security.state === "signature_failed")
893
+ ? security.attachments
894
+ : undefined;
895
+ // Any state that involves a signature shows the address actually signed for (the protected From when the message
896
+ // carries one - it equals the outer From's address, or verification would have failed) next to the badge, never a
897
+ // display name alone: anyone can put "ceo@corp.com" in the name of a message sent from x@corp-pay.com.
898
+ const signatureShown = security !== null && security.state !== "unprotected" && security.state !== "encrypted";
899
+ const senderAddress = (signatureShown ? extractAddresses(security.protectedHeaders?.from)[0] : undefined) ?? message.from.address;
900
+ const senderName = message.from.displayName;
901
+ const senderNameCheck = checkSenderName(senderName, senderAddress);
902
+ // Always `Name <address>` (or the bare address): a name alone hides who a message is really from.
903
+ const senderLabel = formatMailAddress({ displayName: senderName, address: senderAddress });
904
+ // Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
905
+ // A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
906
+ const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
907
+ const trustableCertificate =
908
+ security !== null && UNVERIFIED_SIGNER_STATES.has(security.state) && senderUnpinned && !pendingConflict
909
+ ? security.signerCertificate
910
+ : undefined;
911
+ const keyChanged = security && signerKeyChanged(security) ? security : undefined;
912
+ const pinnedSignerKey = senderKeyState?.pinned[0];
913
+ const recordedConflict =
914
+ keyChanged && sameFingerprint(senderKeyState?.conflict?.observedKey.fingerprint, keyChanged.signerFingerprint)
915
+ ? senderKeyState!.conflict
916
+ : undefined;
917
+
918
+ const shownSubject = (protectedSubject ?? message.subject) || "(no subject)";
919
+ /**
920
+ * How tall the message body is - the one thing on this pane worth every pixel it can have.
921
+ *
922
+ * The same rule in both places: the pane is a full-height flex column and the body is its one growing
923
+ * child, so it fills whatever the header leaves (`min-h-0` on both, or a tall body would stretch the
924
+ * column past the pane instead of scrolling inside it). Inside a thread that height comes from the
925
+ * message's own row, which `ConversationThreadPane` gives `min-h-full` of its scrolling list - so the
926
+ * height is still the window's, resolved down the flex chain rather than written here as a `vh`
927
+ * number, and it follows a resize with no breakpoint to cross.
928
+ *
929
+ * Why not size it to its content: that would need the document *inside* the frame to measure and
930
+ * report itself, and these frames are `sandbox=""` - scripts off - because they render mail from
931
+ * strangers. So a message longer than the pane keeps its own scrollbar, and everything shorter shows
932
+ * whole with the frame reaching the bottom of the pane either way.
933
+ */
934
+ const bodyClassName = "flex-1 min-h-0 w-full";
935
+
936
+ return (
937
+ // `h-full` outside a thread only: the standalone message route renders this pane straight into a
938
+ // *block* (`MailShell`'s own `<main>`), where nothing stretches it and a percentage of that block's
939
+ // own resolved height is the height. Inside a thread the parent is a flex row whose own height came
940
+ // out of the flex algorithm - Chrome won't resolve a percentage against that, and an explicit
941
+ // height would also opt the pane out of the stretching that does size it correctly there.
942
+ <div className={`flex-1 min-w-0 min-h-0 flex flex-col${inThread ? "" : " h-full"}`}>
943
+ <div className="border-b border-border p-4">
944
+ {backHref && (
945
+ <a href={backHref} className="text-sm text-primary-dark hover:underline block mb-2">
946
+ &larr; Back to messages
947
+ </a>
948
+ )}
949
+ <div className="flex items-start justify-between gap-3">
950
+ <div className="flex items-center gap-2 min-w-0">
951
+ {/* One `h1` per document: inside a thread the conversation's own subject is it. */}
952
+ {inThread ? (
953
+ <h3 className="text-sm font-semibold truncate">{shownSubject}</h3>
954
+ ) : (
955
+ <h1 className="text-lg font-bold tracking-tight truncate">{shownSubject}</h1>
956
+ )}
957
+ {security && <SecurityIndicator security={security} />}
958
+ </div>
959
+ {sendInProgress && (
960
+ <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
961
+ Sending&hellip;
962
+ </span>
963
+ )}
964
+ {isSentItems &&
965
+ (message.recallRequestedAt ? (
966
+ <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">
967
+ Recall requested
968
+ </span>
969
+ ) : (
970
+ <Button
971
+ type="button"
972
+ variant="secondary"
973
+ className="!w-auto shrink-0"
974
+ onClick={() => setConfirming(true)}
975
+ >
976
+ Recall this message
977
+ </Button>
978
+ ))}
979
+ {inOutbox && !sendInProgress && message.scheduledSendTime && (
980
+ <div className="flex items-center gap-2 shrink-0">
981
+ <span className="text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt">
982
+ Scheduled for {new Date(message.scheduledSendTime).toLocaleString()}
983
+ </span>
984
+ <Button
985
+ type="button"
986
+ variant="secondary"
987
+ className="!w-auto"
988
+ loading={canceling}
989
+ disabled={canceling}
990
+ onClick={handleCancelScheduledSend}
991
+ >
992
+ Cancel
993
+ </Button>
994
+ </div>
995
+ )}
996
+ {/* A message left in Outbox with no active schedule - a scheduled send that failed or was refused
997
+ (restapi's ScheduledSendJob clears scheduledSendTime and leaves it there), which can't be sent
998
+ again from Outbox (409) or archived - would otherwise be stuck. Moving it back to Drafts works for
999
+ any Outbox message. */}
1000
+ {inOutbox && !sendInProgress && !message.scheduledSendTime && draftsFolderUid && (
1001
+ <Button
1002
+ type="button"
1003
+ variant="secondary"
1004
+ className="!w-auto shrink-0"
1005
+ loading={canceling}
1006
+ disabled={canceling}
1007
+ onClick={handleCancelScheduledSend}
1008
+ >
1009
+ Move to Drafts
1010
+ </Button>
1011
+ )}
1012
+ </div>
1013
+ {inOutbox && message.scheduledSendError && (
1014
+ <div className="mt-2">
1015
+ <Alert>This message wasn&rsquo;t sent: {message.scheduledSendError}</Alert>
1016
+ </div>
1017
+ )}
1018
+ {cancelError && (
1019
+ <div className="mt-2">
1020
+ <Alert>{cancelError}</Alert>
1021
+ </div>
1022
+ )}
1023
+ {keyChangeNotice && (
1024
+ <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1025
+ {keyChangeNotice}
1026
+ </p>
1027
+ )}
1028
+ {security && UNVERIFIED_SIGNER_STATES.has(security.state) && (
1029
+ // Informational: the signature is intact, but nothing ties its certificate to this sender - anyone can
1030
+ // create a certificate naming any address. "Trust this signer" (below) pins it once the reader has
1031
+ // confirmed the fingerprint.
1032
+ <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1033
+ Signed, but the signer isn&rsquo;t a trusted contact key, so the sender isn&rsquo;t verified.
1034
+ {security.signerEmails && security.signerEmails.length > 0 && <> Certificate for {security.signerEmails.join(", ")}.</>}
1035
+ {security.signerFingerprint && (
1036
+ <>
1037
+ {" "}
1038
+ Fingerprint <span className="font-mono text-xs break-all">{security.signerFingerprint}</span>.
1039
+ </>
1040
+ )}
1041
+ </p>
1042
+ )}
1043
+ {pendingConflict && (
1044
+ // Only an unverified signer's result loads the key state while the sender is unpinned, and a conflict
1045
+ // always comes from a matching contact, so its uid is known.
1046
+ <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
1047
+ This sender has a signing key change waiting for your review, so this signer can&rsquo;t be trusted from
1048
+ here.{" "}
1049
+ <a
1050
+ href={`/contacts/${encodeURIComponent(senderKeyState.contactUid!)}`}
1051
+ className="font-medium text-primary-dark hover:underline"
1052
+ >
1053
+ Review it in Contacts
1054
+ </a>
1055
+ </p>
1056
+ )}
1057
+ {trustableCertificate && (
1058
+ <div className="mt-1.5">
1059
+ <Button
1060
+ type="button"
1061
+ variant="secondary"
1062
+ className="!w-auto"
1063
+ disabled={trusting}
1064
+ onClick={() => {
1065
+ setTrustError(null);
1066
+ setTrustConfirmOpen(true);
1067
+ }}
1068
+ >
1069
+ Trust this signer
1070
+ </Button>
1071
+ </div>
1072
+ )}
1073
+ {subjectDiffers && (
1074
+ <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1075
+ The subject shown above is the one the sender signed. It differs from the subject this message was
1076
+ delivered with (&ldquo;{message.subject}&rdquo;), which may have been changed on the way, e.g. by a
1077
+ mailing list.
1078
+ </p>
1079
+ )}
1080
+ {security?.headerTamperDetected && (
1081
+ <div className="mt-2">
1082
+ <Alert>
1083
+ This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or
1084
+ encrypted — an intermediary may have altered them after sending. Treat the fields shown above with
1085
+ caution.
1086
+ </Alert>
1087
+ </div>
1088
+ )}
1089
+ {security?.state === "verified_at_first_open" && (
1090
+ // A seal outranks only a key-status failure (see `evaluateMessageSecurityWithSeal()`). A signer key
1091
+ // later reported compromised is a warning, never reassurance.
1092
+ <p
1093
+ role="status"
1094
+ className={`mt-2 py-2 px-3 rounded-sm text-sm text-text ${security.laterCompromised ? "bg-warning/15" : "bg-surface-alt"}`}
1095
+ >
1096
+ {verifiedAtFirstOpenMessage(security)}
1097
+ </p>
1098
+ )}
1099
+ {keyChanged && (
1100
+ <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">
1101
+ <h2 className="font-semibold">This sender&rsquo;s signing key changed</h2>
1102
+ <p>
1103
+ The signature on this message is valid and its certificate names {senderAddress}, but it was made
1104
+ with a different key than the one you trust for this sender
1105
+ {keyChanged.state === "verified_at_first_open" ? "." : <>, so it isn&rsquo;t verified.</>}
1106
+ </p>
1107
+ <KeyChangeReview
1108
+ mailboxUid={message.mailboxUid}
1109
+ address={senderAddress}
1110
+ useType="sign"
1111
+ ownerName="the sender"
1112
+ current={pinnedSignerKey && { fingerprint: pinnedSignerKey.fingerprint, since: senderKeyState.pinnedSince! }}
1113
+ proposed={{
1114
+ fingerprint: keyChanged.signerFingerprint!,
1115
+ emails: keyChanged.signerEmails,
1116
+ observedAt: recordedConflict?.observedAt,
1117
+ source: recordedConflict?.source,
1118
+ }}
1119
+ certificate={keyChanged.signerCertificate}
1120
+ canReject={recordedConflict !== undefined}
1121
+ canResolve={canUpdateMailbox}
1122
+ onResolved={(action) => refreshAfterKeyChange(action === "reject" ? KEPT_CURRENT_SIGNING_KEY_MESSAGE : null)}
1123
+ onPinnedKeyChanged={() => refreshAfterKeyChange(KEY_CHANGE_STALE_MESSAGE)}
1124
+ />
1125
+ </section>
1126
+ )}
1127
+ {security?.state === "signature_failed" && !keyChanged && (
1128
+ // Deliberately an informational notice, not an error `Alert`: an unverifiable signature
1129
+ // means "don't trust the signer", not "this message is broken" - the body stays readable.
1130
+ <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1131
+ {security.signatureFailureReason
1132
+ ? SIGNATURE_FAILURE_MESSAGE[security.signatureFailureReason]
1133
+ : GENERIC_SIGNATURE_FAILURE_MESSAGE}
1134
+ </p>
1135
+ )}
1136
+ {verifiedOrSealed && !security.protectedHeaders && (
1137
+ // A legacy S/MIME sender signs only the body: the outer Subject/To/Cc shown here were never signed.
1138
+ <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1139
+ The signature covers this message&rsquo;s content and attachments only. Its Subject, To and Cc
1140
+ weren&rsquo;t signed, so they could have been changed after it was sent.
1141
+ </p>
1142
+ )}
1143
+ {security?.notAddressedToReader && (
1144
+ // Informational, like the notice above: a Bcc recipient legitimately sees this too.
1145
+ <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1146
+ The recipients this message was signed or encrypted for don&rsquo;t include this mailbox - it may have
1147
+ been forwarded or re-sent to you unchanged, or you were Bcc&rsquo;d.
1148
+ </p>
1149
+ )}
1150
+ <p className="text-sm text-text-muted mt-1 break-words">
1151
+ From <span className="font-medium text-text">{senderLabel}</span> &middot;{" "}
1152
+ {new Date(message.receivedDate).toLocaleString()}
1153
+ </p>
1154
+ {senderNameCheck.misleading && (
1155
+ <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
1156
+ The sender&rsquo;s name &ldquo;{senderName}&rdquo; looks like an email address, but this message was
1157
+ sent from <span className="font-medium">{senderAddress}</span>. Don&rsquo;t trust it based on the name.
1158
+ </p>
1159
+ )}
1160
+ {/* Every recipient with their address, grouped as the sender addressed them; a long list folds. */}
1161
+ <RecipientLine label="To" recipients={message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")} />
1162
+ <RecipientLine label="Cc" recipients={message.recipients.filter((r) => r.type === "cc")} />
1163
+ <RecipientLine label="Bcc" recipients={message.recipients.filter((r) => r.type === "bcc")} />
1164
+ {/* Wraps: in a thread the pane can be as narrow as the reading pane gets (the list takes
1165
+ 384px of it), and these are six controls. */}
1166
+ <div className="flex flex-wrap gap-1 mt-3">
1167
+ <IconAction
1168
+ icon={<HiOutlineArrowUturnLeft size={16} aria-hidden="true" />}
1169
+ label="Reply"
1170
+ shortcut={keyboard ? SHORTCUTS.mail.reply : undefined}
1171
+ disabled={preparingCompose}
1172
+ onPrefetch={prefetchReply}
1173
+ onClick={() => void handleReplyOrForward("reply")}
1174
+ />
1175
+ <IconAction
1176
+ icon={
1177
+ // The conventional reply-all glyph: the reply arrow, doubled. `hi2` has no
1178
+ // reply-all icon of its own, and nothing else in it means "answer everyone".
1179
+ <span className="inline-flex items-center" aria-hidden="true">
1180
+ <HiOutlineArrowUturnLeft size={16} />
1181
+ <HiOutlineArrowUturnLeft size={16} className="-ml-2.5" />
1182
+ </span>
1183
+ }
1184
+ label="Reply All"
1185
+ shortcut={keyboard ? SHORTCUTS.mail.replyAll : undefined}
1186
+ disabled={preparingCompose}
1187
+ onPrefetch={prefetchReply}
1188
+ onClick={() => void handleReplyOrForward("replyAll")}
1189
+ />
1190
+ <IconAction
1191
+ icon={<HiOutlineArrowUturnRight size={16} aria-hidden="true" />}
1192
+ label="Forward"
1193
+ shortcut={keyboard ? SHORTCUTS.mail.forward : undefined}
1194
+ disabled={preparingCompose}
1195
+ onPrefetch={prefetchReply}
1196
+ onClick={() => void handleReplyOrForward("forward")}
1197
+ />
1198
+ {archivable && (
1199
+ <IconAction
1200
+ icon={<HiOutlineArchiveBox size={16} aria-hidden="true" />}
1201
+ label="Archive"
1202
+ shortcut={keyboard ? SHORTCUTS.mail.archive : undefined}
1203
+ busy={archiving}
1204
+ disabled={archiving}
1205
+ onClick={handleArchive}
1206
+ />
1207
+ )}
1208
+ {movable && (
1209
+ <IconAction
1210
+ icon={<HiOutlineFolderArrowDown size={16} aria-hidden="true" />}
1211
+ label="Move to"
1212
+ shortcut={keyboard ? SHORTCUTS.mail.move : undefined}
1213
+ onClick={() => setMovePrompt(true)}
1214
+ />
1215
+ )}
1216
+ {labels && labels.length > 0 && (
1217
+ <LabelMenuButton
1218
+ aria-label="Labels"
1219
+ label="Labels"
1220
+ className="border border-border py-1.5"
1221
+ labels={labels}
1222
+ mailboxUid={message.mailboxUid}
1223
+ onLabelCreated={onLabelCreated}
1224
+ applied={currentLabelsMessage.labelUids ?? []}
1225
+ onCommit={(labelUids) => void handleApplyLabels(labelUids)}
1226
+ busy={savingLabels}
1227
+ note="Ticked labels are applied to this message and unticked ones removed."
1228
+ emptyNote="This mailbox has no labels yet."
1229
+ commit={{ label: "Apply" }}
1230
+ clear={{ label: "Remove all labels" }}
1231
+ />
1232
+ )}
1233
+ </div>
1234
+ {archiveError && (
1235
+ <div className="mt-2">
1236
+ <Alert>{archiveError}</Alert>
1237
+ </div>
1238
+ )}
1239
+ {labelsError && (
1240
+ <div className="mt-2">
1241
+ <Alert>{labelsError}</Alert>
1242
+ </div>
1243
+ )}
1244
+ {labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (
1245
+ <div className="flex flex-wrap items-center gap-1.5 mt-2">
1246
+ {currentLabelsMessage
1247
+ .labelUids!.map((uid) => labels.find((l) => l.uid === uid))
1248
+ .filter((l): l is Label => !!l)
1249
+ .map((l) => (
1250
+ <span
1251
+ key={l.uid}
1252
+ className="inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt"
1253
+ >
1254
+ <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
1255
+ {l.name}
1256
+ </span>
1257
+ ))}
1258
+ </div>
1259
+ )}
1260
+ {/* A failed reclassification is reported inside the confirmation dialog it was started
1261
+ from, next to the Move button that would retry it - not out here behind it. */}
1262
+ {(["delivery", "read"] as const)
1263
+ .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
1264
+ .map((type) => (
1265
+ <div
1266
+ key={type}
1267
+ className="flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm"
1268
+ >
1269
+ <span>
1270
+ {senderLabel} requested a {type} receipt for this
1271
+ message.
1272
+ </span>
1273
+ <Button
1274
+ type="button"
1275
+ variant="secondary"
1276
+ className="!w-auto"
1277
+ loading={receiptBusy === type}
1278
+ disabled={receiptBusy !== null}
1279
+ onClick={() => handleReceipt(type, "approve")}
1280
+ >
1281
+ Send receipt
1282
+ </Button>
1283
+ <Button
1284
+ type="button"
1285
+ variant="text"
1286
+ disabled={receiptBusy !== null}
1287
+ onClick={() => handleReceipt(type, "decline")}
1288
+ >
1289
+ Decline
1290
+ </Button>
1291
+ </div>
1292
+ ))}
1293
+ {receiptError && (
1294
+ <div className="mt-2">
1295
+ <Alert>{receiptError}</Alert>
1296
+ </div>
1297
+ )}
1298
+ {security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (
1299
+ <p role="status" className="mt-3 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
1300
+ These attachments come from a message whose signature couldn&rsquo;t be verified. Open them only if
1301
+ you trust the sender.
1302
+ </p>
1303
+ )}
1304
+ {innerAttachments
1305
+ ? innerAttachments.length > 0 && (
1306
+ <ul className="flex flex-wrap gap-2 mt-3">
1307
+ {innerAttachments.map((attachment, index) => (
1308
+ <li key={`${index}:${attachment.filename ?? ""}`}>
1309
+ <button
1310
+ type="button"
1311
+ onClick={() => downloadMimeAttachment(attachment)}
1312
+ className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
1313
+ >
1314
+ {attachment.filename ?? `Unnamed ${attachment.contentType} attachment`}
1315
+ </button>
1316
+ </li>
1317
+ ))}
1318
+ </ul>
1319
+ )
1320
+ : attachments.length > 0 && (
1321
+ <ul className="flex flex-wrap gap-2 mt-3">
1322
+ {attachments.map((attachment) => (
1323
+ <li key={attachment.uid}>
1324
+ <a
1325
+ href={attachmentContentUrl(attachment.uid)}
1326
+ className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
1327
+ >
1328
+ {attachment.filename} ({formatBytes(attachment.sizeBytes)})
1329
+ </a>
1330
+ </li>
1331
+ ))}
1332
+ </ul>
1333
+ )}
1334
+ </div>
1335
+ {security?.decryptError && (
1336
+ <div className="px-4 pt-2">
1337
+ <Alert>{security.decryptError}</Alert>
1338
+ {!getUnlockedKeys(message.mailboxUid) && (
1339
+ <button
1340
+ type="button"
1341
+ onClick={handleUnlockToView}
1342
+ className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
1343
+ >
1344
+ <HiOutlineLockClosed size={12} aria-hidden="true" />
1345
+ Unlock to view this message
1346
+ </button>
1347
+ )}
1348
+ </div>
1349
+ )}
1350
+ {security?.text !== undefined ? (
1351
+ // A recovered text/plain body renders as text (React escapes it) - never as markup.
1352
+ <pre
1353
+ aria-label={message.subject || "Message content"}
1354
+ className={`${bodyClassName} overflow-auto p-4 m-0 text-sm font-sans whitespace-pre-wrap break-words`}
1355
+ >
1356
+ {security.text}
1357
+ </pre>
1358
+ ) : security?.html !== undefined ? (
1359
+ // A decrypted/verified body never came through the server's own sanitize-html pass (it
1360
+ // couldn't - the server never saw the plaintext) - it's sanitized here, client-side, before
1361
+ // it ever touches the DOM (remote images/stylesheets/`url()`s stripped too, so opening it
1362
+ // can't ping a tracker), behind a no-remote-loads CSP, on top of the iframe's `sandbox=""`.
1363
+ <iframe
1364
+ key={message.uid}
1365
+ title={message.subject || "Message content"}
1366
+ srcDoc={buildSecureSrcDoc(security.html)}
1367
+ sandbox=""
1368
+ className={`${bodyClassName} border-0`}
1369
+ />
1370
+ ) : (
1371
+ <iframe
1372
+ key={message.uid}
1373
+ title={message.subject || "Message content"}
1374
+ src={`/api/mail/messages/${encodeURIComponent(message.uid)}/content`}
1375
+ sandbox=""
1376
+ className={`${bodyClassName} border-0`}
1377
+ />
1378
+ )}
1379
+
1380
+ <MoveToFolderDialog
1381
+ open={movePrompt}
1382
+ onClose={() => setMovePrompt(false)}
1383
+ mailboxUid={message.mailboxUid}
1384
+ folders={folders ?? []}
1385
+ currentFolderUid={message.folderUid}
1386
+ onMove={handleMove}
1387
+ onFolderCreated={onFolderCreated}
1388
+ />
1389
+ <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
1390
+ <p className="text-sm text-text-muted mb-4">
1391
+ This asks every original recipient's mail system to delete their copy, but only if it's
1392
+ still unread there — there's no way to guarantee it, and no confirmation once it either
1393
+ succeeds or fails. Recipients who already read the message will keep it.
1394
+ </p>
1395
+ {error && <Alert>{error}</Alert>}
1396
+ <div className="flex gap-3">
1397
+ <Button type="button" loading={recalling} disabled={recalling} onClick={handleRecall} className="!w-auto">
1398
+ Recall message
1399
+ </Button>
1400
+ <Button
1401
+ type="button"
1402
+ variant="secondary"
1403
+ disabled={recalling}
1404
+ onClick={() => setConfirming(false)}
1405
+ className="!w-auto"
1406
+ >
1407
+ Cancel
1408
+ </Button>
1409
+ </div>
1410
+ </Modal>
1411
+ <Modal
1412
+ open={trustConfirmOpen && !!trustableCertificate}
1413
+ onClose={() => !trusting && setTrustConfirmOpen(false)}
1414
+ title="Trust this signer?"
1415
+ >
1416
+ <div className="flex flex-col gap-3 text-sm">
1417
+ <p>
1418
+ Mail from <span className="font-medium">{senderAddress}</span> signed with this certificate will show as
1419
+ verified.
1420
+ </p>
1421
+ <dl className="flex flex-col gap-1">
1422
+ <dt className="text-xs text-text-muted">Certificate issued to</dt>
1423
+ <dd>{security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address"}</dd>
1424
+ <dt className="text-xs text-text-muted">Fingerprint</dt>
1425
+ <dd className="font-mono text-xs break-all">
1426
+ {security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown"}
1427
+ </dd>
1428
+ </dl>
1429
+ <p className="text-text-muted">
1430
+ Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with
1431
+ the sender through another channel, such as a phone call.
1432
+ </p>
1433
+ {trustError && <Alert>{trustError}</Alert>}
1434
+ <div className="flex gap-3">
1435
+ <Button
1436
+ type="button"
1437
+ className="!w-auto"
1438
+ loading={trusting}
1439
+ disabled={trusting}
1440
+ onClick={() => handleTrustSigner(senderAddress, trustableCertificate!)}
1441
+ >
1442
+ Trust
1443
+ </Button>
1444
+ <Button
1445
+ type="button"
1446
+ variant="secondary"
1447
+ className="!w-auto"
1448
+ disabled={trusting}
1449
+ onClick={() => setTrustConfirmOpen(false)}
1450
+ >
1451
+ Cancel
1452
+ </Button>
1453
+ </div>
1454
+ </div>
1455
+ </Modal>
1456
+ </div>
1457
+ );
1458
+ }