@rapidmx/web-client 0.10.0 → 0.11.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 (310) hide show
  1. package/README.md +183 -2
  2. package/apps/admin/_layout.tsx +8 -2
  3. package/apps/admin/index.tsx +7 -1
  4. package/apps/admin/ingest-queue/index.tsx +2 -1
  5. package/apps/admin/mailboxes/[uid].tsx +23 -10
  6. package/apps/admin/quarantine/index.tsx +2 -1
  7. package/apps/admin/signing-certificates/index.tsx +343 -0
  8. package/apps/escrow/_layout.tsx +8 -2
  9. package/apps/shared/appearance/AppearanceForm.tsx +392 -0
  10. package/apps/shared/appearance/AppearanceHead.tsx +49 -0
  11. package/apps/shared/appearance/AppearanceProvider.tsx +470 -0
  12. package/apps/shared/appearance/ColorField.tsx +107 -0
  13. package/apps/shared/appearance/appearanceCache.ts +106 -0
  14. package/apps/shared/appearance/appearanceContext.ts +77 -0
  15. package/apps/shared/appearance/bootScript.ts +35 -0
  16. package/apps/shared/appearance/color.ts +169 -0
  17. package/apps/shared/appearance/photo.ts +88 -0
  18. package/apps/shared/appearance/resolvedTheme.ts +67 -0
  19. package/apps/shared/appearance/theme.ts +355 -0
  20. package/apps/shared/components/admin/layout/AdminShell.tsx +22 -10
  21. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +107 -60
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +24 -0
  23. package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
  24. package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
  25. package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
  26. package/apps/shared/components/contacts/ContactForm.tsx +11 -5
  27. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
  28. package/apps/shared/components/contacts/ContactsToolbar.tsx +25 -52
  29. package/apps/shared/components/contacts/layout/ContactsShell.tsx +10 -5
  30. package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
  31. package/apps/shared/components/layout/AppShell.tsx +98 -32
  32. package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
  33. package/apps/shared/components/layout/RailIcon.tsx +100 -0
  34. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
  35. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
  36. package/apps/shared/components/layout/UserMenu.tsx +75 -6
  37. package/apps/shared/components/mail/ConversationList.tsx +4 -2
  38. package/apps/shared/components/mail/ConversationThreadPane.tsx +487 -475
  39. package/apps/shared/components/mail/LazyReadingPane.tsx +14 -16
  40. package/apps/shared/components/mail/MailSelectionBar.tsx +2 -10
  41. package/apps/shared/components/mail/MessageDetailPane.tsx +366 -287
  42. package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
  43. package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
  44. package/apps/shared/components/mail/compose/ComposeContext.tsx +35 -2
  45. package/apps/shared/components/mail/compose/ComposeWindow.tsx +239 -437
  46. package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
  47. package/apps/shared/components/mail/layout/MailShell.tsx +39 -25
  48. package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
  49. package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
  50. package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
  51. package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
  52. package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
  53. package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
  54. package/apps/shared/components/mail/reading/color.ts +245 -0
  55. package/apps/shared/components/mail/reading/frameControl.ts +142 -0
  56. package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
  57. package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
  58. package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
  59. package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
  60. package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
  61. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
  62. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  63. package/apps/shared/components/sharing/PrincipalPicker.tsx +145 -0
  64. package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
  65. package/apps/shared/components/tasks/layout/TasksShell.tsx +9 -5
  66. package/apps/shared/mail/folderCounts.ts +9 -3
  67. package/apps/shared/mail/folderTree.ts +143 -0
  68. package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
  69. package/apps/shared/mail/outbox/composeBridge.ts +69 -0
  70. package/apps/shared/mail/outbox/outboxState.ts +88 -0
  71. package/apps/shared/mail/outbox/pendingSends.ts +146 -0
  72. package/apps/shared/mail/outbox/sendDecision.ts +157 -0
  73. package/apps/shared/mail/outbox/sendJob.ts +427 -0
  74. package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
  75. package/apps/shared/mail/outbox/sendState.ts +35 -0
  76. package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
  77. package/apps/shared/mail/useMailConnection.ts +81 -28
  78. package/apps/shared/mail/useMailLiveUpdates.ts +55 -10
  79. package/apps/shared/mail/useNewMailNotifications.ts +47 -32
  80. package/apps/shared/navigation/AppRouter.tsx +2 -2
  81. package/apps/shared/notifications/NotificationCenter.tsx +215 -0
  82. package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
  83. package/apps/shared/notifications/apiErrors.ts +98 -0
  84. package/apps/shared/notifications/headerOffset.ts +35 -0
  85. package/apps/shared/notifications/pushStatus.ts +56 -0
  86. package/apps/shared/notifications/store.ts +549 -0
  87. package/apps/shared/notifications/systemErrors.ts +63 -0
  88. package/apps/shared/notifications/useNotifications.ts +41 -0
  89. package/apps/shared/signing/enrollmentStorage.ts +33 -0
  90. package/apps/shared/signing/enrollmentTracker.ts +385 -0
  91. package/apps/shared/signing/enrollmentView.ts +251 -0
  92. package/apps/shared/signing/signingInfo.ts +58 -0
  93. package/apps/shared/signing/useNow.ts +19 -0
  94. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
  95. package/apps/shared/styles/app.css +80 -0
  96. package/apps/www/_layout.tsx +8 -2
  97. package/apps/www/_routes.ts +1 -0
  98. package/apps/www/calendar/index.tsx +9 -14
  99. package/apps/www/contacts/[uid].tsx +5 -8
  100. package/apps/www/contacts/index.tsx +587 -584
  101. package/apps/www/index.tsx +38 -27
  102. package/apps/www/messages/[uid].tsx +3 -1
  103. package/apps/www/settings/appearance/index.tsx +21 -0
  104. package/apps/www/settings/auto-reply/index.tsx +3 -7
  105. package/apps/www/settings/encryption/index.tsx +151 -116
  106. package/apps/www/settings/filters/[uid].tsx +3 -1
  107. package/apps/www/settings/filters/new/index.tsx +3 -1
  108. package/apps/www/settings/privacy/index.tsx +3 -8
  109. package/apps/www/settings/read-receipts/index.tsx +3 -7
  110. package/apps/www/settings/sharing/index.tsx +44 -65
  111. package/apps/www/settings/signatures/[uid].tsx +3 -1
  112. package/apps/www/settings/signatures/new/index.tsx +3 -2
  113. package/apps/www/tasks/index.tsx +11 -16
  114. package/dist/apps/admin/_layout.d.ts +5 -1
  115. package/dist/apps/admin/_layout.js +3 -2
  116. package/dist/apps/admin/index.js +3 -2
  117. package/dist/apps/admin/ingest-queue/index.js +2 -1
  118. package/dist/apps/admin/mailboxes/[uid].js +9 -6
  119. package/dist/apps/admin/quarantine/index.js +2 -1
  120. package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
  121. package/dist/apps/admin/signing-certificates/index.js +161 -0
  122. package/dist/apps/escrow/_layout.d.ts +5 -1
  123. package/dist/apps/escrow/_layout.js +3 -2
  124. package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
  125. package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
  126. package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
  127. package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
  128. package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
  129. package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
  130. package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
  131. package/dist/apps/shared/appearance/ColorField.js +40 -0
  132. package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
  133. package/dist/apps/shared/appearance/appearanceCache.js +80 -0
  134. package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
  135. package/dist/apps/shared/appearance/appearanceContext.js +29 -0
  136. package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
  137. package/dist/apps/shared/appearance/bootScript.js +33 -0
  138. package/dist/apps/shared/appearance/color.d.ts +65 -0
  139. package/dist/apps/shared/appearance/color.js +132 -0
  140. package/dist/apps/shared/appearance/photo.d.ts +17 -0
  141. package/dist/apps/shared/appearance/photo.js +79 -0
  142. package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
  143. package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
  144. package/dist/apps/shared/appearance/theme.d.ts +111 -0
  145. package/dist/apps/shared/appearance/theme.js +251 -0
  146. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  147. package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
  148. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
  149. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
  150. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
  151. package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
  152. package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
  153. package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
  154. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
  155. package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
  156. package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
  157. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  158. package/dist/apps/shared/components/contacts/ContactsToolbar.js +24 -9
  159. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -1
  160. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
  161. package/dist/apps/shared/components/layout/AppShell.d.ts +6 -1
  162. package/dist/apps/shared/components/layout/AppShell.js +59 -13
  163. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
  164. package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
  165. package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
  166. package/dist/apps/shared/components/layout/RailIcon.js +85 -0
  167. package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
  168. package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
  169. package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
  170. package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
  171. package/dist/apps/shared/components/layout/UserMenu.d.ts +7 -1
  172. package/dist/apps/shared/components/layout/UserMenu.js +41 -7
  173. package/dist/apps/shared/components/mail/ConversationList.js +3 -2
  174. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +2 -1
  175. package/dist/apps/shared/components/mail/ConversationThreadPane.js +42 -20
  176. package/dist/apps/shared/components/mail/LazyReadingPane.js +9 -8
  177. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +3 -4
  178. package/dist/apps/shared/components/mail/MailSelectionBar.js +2 -3
  179. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +18 -7
  180. package/dist/apps/shared/components/mail/MessageDetailPane.js +86 -41
  181. package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
  182. package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
  183. package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
  184. package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
  185. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +8 -0
  186. package/dist/apps/shared/components/mail/compose/ComposeContext.js +28 -3
  187. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +2 -1
  188. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +229 -328
  189. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
  190. package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
  191. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +2 -0
  192. package/dist/apps/shared/components/mail/layout/MailShell.js +20 -14
  193. package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
  194. package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
  195. package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
  196. package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
  197. package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
  198. package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
  199. package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
  200. package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
  201. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
  202. package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
  203. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
  204. package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
  205. package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
  206. package/dist/apps/shared/components/mail/reading/color.js +201 -0
  207. package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
  208. package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
  209. package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
  210. package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
  211. package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
  212. package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
  213. package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
  214. package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
  215. package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
  216. package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
  217. package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
  218. package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
  219. package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
  220. package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
  221. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  222. package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +26 -0
  223. package/dist/apps/shared/components/sharing/PrincipalPicker.js +77 -0
  224. package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
  225. package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -1
  226. package/dist/apps/shared/mail/folderCounts.d.ts +1 -1
  227. package/dist/apps/shared/mail/folderCounts.js +9 -3
  228. package/dist/apps/shared/mail/folderTree.d.ts +55 -0
  229. package/dist/apps/shared/mail/folderTree.js +116 -0
  230. package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
  231. package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
  232. package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
  233. package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
  234. package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
  235. package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
  236. package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
  237. package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
  238. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
  239. package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
  240. package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
  241. package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
  242. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
  243. package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
  244. package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
  245. package/dist/apps/shared/mail/outbox/sendState.js +8 -0
  246. package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
  247. package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
  248. package/dist/apps/shared/mail/useMailConnection.d.ts +9 -8
  249. package/dist/apps/shared/mail/useMailConnection.js +64 -22
  250. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +17 -4
  251. package/dist/apps/shared/mail/useMailLiveUpdates.js +36 -10
  252. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +4 -10
  253. package/dist/apps/shared/mail/useNewMailNotifications.js +43 -23
  254. package/dist/apps/shared/navigation/AppRouter.js +2 -2
  255. package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
  256. package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
  257. package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
  258. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
  259. package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
  260. package/dist/apps/shared/notifications/apiErrors.js +86 -0
  261. package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
  262. package/dist/apps/shared/notifications/headerOffset.js +33 -0
  263. package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
  264. package/dist/apps/shared/notifications/pushStatus.js +54 -0
  265. package/dist/apps/shared/notifications/store.d.ts +136 -0
  266. package/dist/apps/shared/notifications/store.js +416 -0
  267. package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
  268. package/dist/apps/shared/notifications/systemErrors.js +58 -0
  269. package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
  270. package/dist/apps/shared/notifications/useNotifications.js +18 -0
  271. package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
  272. package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
  273. package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
  274. package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
  275. package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
  276. package/dist/apps/shared/signing/enrollmentView.js +214 -0
  277. package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
  278. package/dist/apps/shared/signing/signingInfo.js +48 -0
  279. package/dist/apps/shared/signing/useNow.d.ts +2 -0
  280. package/dist/apps/shared/signing/useNow.js +18 -0
  281. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
  282. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
  283. package/dist/apps/shared/styles/app.css +80 -0
  284. package/dist/apps/www/_layout.d.ts +5 -1
  285. package/dist/apps/www/_layout.js +3 -2
  286. package/dist/apps/www/_routes.js +1 -0
  287. package/dist/apps/www/calendar/index.js +3 -4
  288. package/dist/apps/www/contacts/[uid].js +5 -7
  289. package/dist/apps/www/contacts/index.js +14 -21
  290. package/dist/apps/www/index.js +37 -19
  291. package/dist/apps/www/messages/[uid].js +3 -1
  292. package/dist/apps/www/settings/appearance/index.d.ts +4 -0
  293. package/dist/apps/www/settings/appearance/index.js +13 -0
  294. package/dist/apps/www/settings/auto-reply/index.js +3 -6
  295. package/dist/apps/www/settings/encryption/index.js +103 -95
  296. package/dist/apps/www/settings/filters/[uid].js +3 -1
  297. package/dist/apps/www/settings/filters/new/index.js +3 -1
  298. package/dist/apps/www/settings/privacy/index.js +5 -8
  299. package/dist/apps/www/settings/read-receipts/index.js +3 -6
  300. package/dist/apps/www/settings/sharing/index.js +19 -33
  301. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  302. package/dist/apps/www/settings/signatures/new/index.js +3 -2
  303. package/dist/apps/www/tasks/index.js +8 -14
  304. package/package.json +2 -2
  305. package/apps/shared/components/mail/NewMailToasts.tsx +0 -141
  306. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
  307. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +0 -19
  308. package/dist/apps/shared/components/mail/NewMailToasts.js +0 -55
  309. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
  310. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
@@ -11,6 +11,8 @@ import {
11
11
  HiOutlineExclamationTriangle,
12
12
  HiOutlineFolderArrowDown,
13
13
  HiOutlineLockClosed,
14
+ HiOutlineMoon,
15
+ HiOutlineSun,
14
16
  } from "react-icons/hi2";
15
17
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
16
18
  import {
@@ -40,7 +42,6 @@ import {
40
42
  forwardSubject,
41
43
  replySubject,
42
44
  } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
43
- import { sanitizeMessageBodyHtml } from "@rapidmx/react-shared/mail/messageBodySanitizer.js";
44
45
  import MoveToFolderDialog from "./MoveToFolderDialog.js";
45
46
  import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
46
47
  import type { MessageSecurityResult, SignatureFailureReason } from "@rapidmx/react-shared/crypto/messageSecurity.js";
@@ -56,7 +57,7 @@ import { ComposeLateInput, prefetchComposeWindow, useCompose } from "./compose/C
56
57
  import { loadOriginalMessage, prefetchOriginalMessage } from "./compose/quotedBody.js";
57
58
  import { formatRecipient } from "./compose/recipients.js";
58
59
  import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
59
- import { RecipientLine } from "./MailAddress.js";
60
+ import MailAddress, { RecipientLine } from "./MailAddress.js";
60
61
  import { useMailShell } from "./layout/MailShell.js";
61
62
  import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
62
63
  import { SHORTCUTS, ShortcutDef } from "../../keyboard/keymap.js";
@@ -68,6 +69,13 @@ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
68
69
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
69
70
  import LabelMenuButton from "./labelMenu.js";
70
71
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
72
+ import EncryptedBody from "./reading/EncryptedBody.js";
73
+ import { displaySubject } from "./reading/EncryptedPreview.js";
74
+ import MessageBody, { BodySkeleton } from "./reading/MessageBody.js";
75
+ import { BODY_FONT_STYLE, CardShell, SenderAvatar, SubjectCard } from "./reading/MessageCard.js";
76
+ import type { BodyContent } from "./reading/bodyContent.js";
77
+ import { useViewOriginal } from "./reading/viewOriginal.js";
78
+ import { ROW_FOCUS_CLASS, UnreadLabel, dateClass, senderClass } from "./unreadStyle.js";
71
79
 
72
80
  /** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
73
81
  * plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
@@ -229,7 +237,14 @@ function IconAction({
229
237
  function SecurityIndicator({ security }: { security: MessageSecurityResult }) {
230
238
  const { label, className } = SECURITY_INDICATOR[security.state];
231
239
  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>;
240
+ // Every encrypted state carries a small lock, so the badge reads at a glance; the words are unchanged.
241
+ const encrypted = security.state.startsWith("encrypted");
242
+ return (
243
+ <span className={`inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`}>
244
+ {encrypted && <HiOutlineLockClosed size={12} aria-hidden="true" />}
245
+ {label}
246
+ </span>
247
+ );
233
248
  }
234
249
  return (
235
250
  <span
@@ -267,18 +282,6 @@ const GENERIC_SIGNATURE_FAILURE_MESSAGE = "This message's digital signature coul
267
282
  * message must never be mislabeled "Unprotected" just because loading failed. */
268
283
  const ENCRYPTED_LOAD_ERROR = "Couldn't load this message's encrypted content.";
269
284
 
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
285
  export interface MessageDetailPaneProps {
283
286
  message: Message | null;
284
287
  attachments: Attachment[];
@@ -338,10 +341,23 @@ export interface MessageDetailPaneProps {
338
341
  /** Handed a label created from the Labels menu, for the caller to add to `labels`. Without it the menu
339
342
  * offers no "New label" row, only the link to where labels are managed. */
340
343
  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
+ /** Rendered as one message of a thread (`ConversationThreadPane`) rather than as the pane itself: only its card is drawn - the thread
345
+ * owns the subject card, the scrolling and the `h1` - and the message's own subject appears (as an `h3`) only when it differs from the
346
+ * thread's (`threadSubject`). Everything else - the badges, the actions, the body - is identical. */
344
347
  inThread?: boolean;
348
+ /** The thread's subject, for a message of a thread to tell whether its own subject is worth showing. */
349
+ threadSubject?: string;
350
+ /** In a thread, what makes the sender line of this expanded card the button that collapses it: the id of the body it controls, whether the
351
+ * message is unread (which draws the accent bar and tint), what it does, and where to put its ref. */
352
+ threadHeader?: {
353
+ bodyId: string;
354
+ unread: boolean;
355
+ onToggle: () => void;
356
+ buttonRef: (node: HTMLButtonElement | null) => void;
357
+ };
358
+ /** Draw the Reply and Forward buttons at the foot of the card - by default for the single-message pane, and in a thread only where the
359
+ * caller asks (the newest expanded message). */
360
+ footer?: boolean;
345
361
  /**
346
362
  * Registers this pane's keyboard shortcuts - Reply, Reply all, Forward, Archive, Move to - so they act on this message. Only the message
347
363
  * 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
@@ -350,6 +366,16 @@ export interface MessageDetailPaneProps {
350
366
  shortcuts?: boolean;
351
367
  }
352
368
 
369
+ /** A subject without its \`Re:\`/\`Fwd:\` prefixes, lower-cased - what makes two messages of one thread the same subject. */
370
+ function subjectCore(subject: string | undefined): string {
371
+ return (subject ?? "").replace(/^(\s*(re|fw|fwd|aw|sv)\s*:)+/i, "").trim().toLowerCase();
372
+ }
373
+
374
+ /** Whether a message's subject says nothing its thread's subject doesn't. */
375
+ function sameSubject(subject: string, threadSubject: string | undefined): boolean {
376
+ return subjectCore(subject) === subjectCore(threadSubject);
377
+ }
378
+
353
379
  function formatBytes(bytes: number): string {
354
380
  if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
355
381
  if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
@@ -357,7 +383,8 @@ function formatBytes(bytes: number): string {
357
383
  }
358
384
 
359
385
  /**
360
- * A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
386
+ * A message's reading pane - a subject card, and under it the message as a card: header (sender, recipients, time, actions), notices, attachments,
387
+ * the body (`reading/MessageBody`: an isolated frame exactly as tall as its content, or text) and a footer. Rendered with `inThread`, only the card.
361
388
  * Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
362
389
  * and the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
363
390
  * a message row) — see each call site for how `message`/`attachments`/`isSentItems` are sourced. The
@@ -399,6 +426,9 @@ function MessageDetailContent({
399
426
  onLabelsChanged,
400
427
  onLabelCreated,
401
428
  inThread,
429
+ threadSubject,
430
+ threadHeader,
431
+ footer,
402
432
  shortcuts,
403
433
  }: MessageDetailPaneProps & { message: Message }) {
404
434
  // A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
@@ -450,6 +480,14 @@ function MessageDetailContent({
450
480
  // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
451
481
  // state; see keySession.ts's own doc comment).
452
482
  const [unlockRefresh, setUnlockRefresh] = useState(0);
483
+ // "View original": this message exactly as its author wrote it instead of adapted to the theme, remembered for the session. Offered only
484
+ // once the body says adapting would change something (`MessageBody`'s `onAdaptable`), or when the reader already chose it.
485
+ const [viewOriginal, setViewOriginal] = useViewOriginal(messageProp.uid);
486
+ const [adaptable, setAdaptable] = useState(viewOriginal);
487
+ // The locked body's Unlock button is waiting on the unlock prompt; and, once it has answered, the focus is to move to the body region.
488
+ const [unlocking, setUnlocking] = useState(false);
489
+ const focusBodyRef = useRef(false);
490
+ const bodyRegionRef = useRef<HTMLDivElement>(null);
453
491
 
454
492
  // The newest copy of this message known to the Labels popover - normally just the `message` prop,
455
493
  // but a successful label toggle's server response is held here too, so a follow-up toggle always
@@ -576,15 +614,34 @@ function MessageDetailContent({
576
614
  };
577
615
  }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey]);
578
616
 
617
+ // Whether the body is the locked state right now (an encrypted message this device holds no unlocked keys for), for the subscription below.
618
+ const lockedRef = useRef(false);
619
+ lockedRef.current = security?.decryptError !== undefined && !getUnlockedKeys(message.mailboxUid);
620
+
621
+ // After an unlock from the button, the message (or the reason it can't be read) is where the reader's attention is: the focus moves to it once it is
622
+ // there, since the button that had it is gone.
623
+ useEffect(() => {
624
+ if (focusBodyRef.current && security !== null) {
625
+ focusBodyRef.current = false;
626
+ bodyRegionRef.current?.focus({ preventScroll: true });
627
+ }
628
+ }, [security]);
629
+
579
630
  // Decrypted plaintext must not outlive the key session that produced it: the moment this mailbox's
580
631
  // 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.
632
+ // re-evaluate - an encrypted message then falls back to its locked state. And the other way round: keys unlocked
633
+ // from anywhere (compose, another message, the unlock prompt) open a message that was waiting on them, in place.
582
634
  useEffect(
583
635
  () =>
584
636
  subscribeKeySession((event) => {
585
- if (event.mailboxUid === message.mailboxUid && event.state === "locked") {
637
+ if (event.mailboxUid !== message.mailboxUid) {
638
+ return;
639
+ }
640
+ if (event.state === "locked") {
586
641
  setSecurity(null);
587
642
  setUnlockRefresh((n) => n + 1);
643
+ } else if (lockedRef.current) {
644
+ setUnlockRefresh((n) => n + 1);
588
645
  }
589
646
  }),
590
647
  [message.mailboxUid],
@@ -622,11 +679,16 @@ function MessageDetailContent({
622
679
  async function handleUnlockToView() {
623
680
  const mailboxUid = message.mailboxUid;
624
681
  const mailboxKeys = mailboxes.find((mb) => mb.uid === mailboxUid)?.keys ?? [];
682
+ setUnlocking(true);
625
683
  try {
626
684
  await requestUnlock(mailboxUid, mailboxKeys);
685
+ // The Unlock button is about to be replaced by the message (or by why it can't be read): the focus goes there, once it has been evaluated.
686
+ focusBodyRef.current = true;
627
687
  setUnlockRefresh((n) => n + 1);
628
688
  } catch {
629
689
  // User dismissed the unlock dialog - security state stays exactly as it was.
690
+ } finally {
691
+ setUnlocking(false);
630
692
  }
631
693
  }
632
694
 
@@ -915,113 +977,207 @@ function MessageDetailContent({
915
977
  ? senderKeyState!.conflict
916
978
  : undefined;
917
979
 
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";
980
+ const shownSubject = (protectedSubject ?? displaySubject(message.subject)) || "(no subject)";
981
+ // What the body area shows. Text or HTML the client recovered from a signed or encrypted message is shown as it is (its own sanitizing
982
+ // happens in `MessageBody`, like every body's); anything else is the server's own `/content`. An encrypted message has nothing worth
983
+ // fetching until it has been decrypted (the server only has the ciphertext), so it shows the skeleton meanwhile.
984
+ const bodyContent: BodyContent | undefined =
985
+ security?.text !== undefined
986
+ ? { kind: "text", text: security.text }
987
+ : security?.html !== undefined
988
+ ? { kind: "html", html: security.html }
989
+ : undefined;
990
+ const bodyTitle = message.subject || "Message content";
991
+ const answered = message.flags.answered === true;
992
+ const forwarded = message.flags.forwarded === true;
993
+ const cardUnread = threadHeader?.unread ?? false;
994
+ const showFooter = footer ?? !inThread;
935
995
 
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>
996
+ const senderRecipient = { displayName: senderName, address: senderAddress };
997
+ const sendingStatus = (
998
+ <>
999
+ {sendInProgress && (
1000
+ <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">Sending&hellip;</span>
1001
+ )}
1002
+ {isSentItems &&
1003
+ (message.recallRequestedAt ? (
1004
+ <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt">Recall requested</span>
1005
+ ) : (
1006
+ <Button type="button" variant="secondary" className="!w-auto shrink-0" onClick={() => setConfirming(true)}>
1007
+ Recall this message
1008
+ </Button>
1009
+ ))}
1010
+ {inOutbox && !sendInProgress && message.scheduledSendTime && (
1011
+ <div className="flex items-center gap-2 shrink-0">
1012
+ <span className="text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt">
1013
+ Scheduled for {new Date(message.scheduledSendTime).toLocaleString()}
1014
+ </span>
1015
+ <Button type="button" variant="secondary" className="!w-auto" loading={canceling} disabled={canceling} onClick={handleCancelScheduledSend}>
1016
+ Cancel
1017
+ </Button>
1018
+ </div>
1019
+ )}
1020
+ {/* A message left in Outbox with no active schedule - a scheduled send that failed or was refused
1021
+ (restapi's ScheduledSendJob clears scheduledSendTime and leaves it there), which can't be sent
1022
+ again from Outbox (409) or archived - would otherwise be stuck. Moving it back to Drafts works for
1023
+ any Outbox message. */}
1024
+ {inOutbox && !sendInProgress && !message.scheduledSendTime && draftsFolderUid && (
1025
+ <Button type="button" variant="secondary" className="!w-auto shrink-0" loading={canceling} disabled={canceling} onClick={handleCancelScheduledSend}>
1026
+ Move to Drafts
1027
+ </Button>
1028
+ )}
1029
+ </>
1030
+ );
1031
+
1032
+ const card = (
1033
+ <CardShell unread={cardUnread}>
1034
+ {/* Header row: who it is from, when, and what can be done with it. Wraps: on a phone the actions drop under the sender. */}
1035
+ <div className={["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" ")}>
1036
+ <SenderAvatar from={senderRecipient} />
1037
+ <div className="flex-1 min-w-[12rem]">
1038
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
1039
+ {threadHeader ? (
1040
+ // The sender line of a message in a thread is the button that collapses it again.
1041
+ <h2 className="min-w-0 flex-1" style={BODY_FONT_STYLE}>
1042
+ <button
1043
+ type="button"
1044
+ ref={threadHeader.buttonRef}
1045
+ onClick={threadHeader.onToggle}
1046
+ aria-expanded={true}
1047
+ aria-controls={threadHeader.bodyId}
1048
+ className={[
1049
+ "w-full text-left rounded-sm text-sm",
1050
+ ROW_FOCUS_CLASS,
1051
+ ].join(" ")}
1052
+ >
1053
+ <UnreadLabel unread={cardUnread} />
1054
+ <MailAddress recipient={senderRecipient} className={senderClass(cardUnread)} />
1055
+ </button>
1056
+ </h2>
954
1057
  ) : (
955
- <h1 className="text-lg font-bold tracking-tight truncate">{shownSubject}</h1>
1058
+ <p className="text-sm break-words min-w-0">
1059
+ From <span className="font-semibold text-text">{senderLabel}</span>
1060
+ </p>
956
1061
  )}
957
1062
  {security && <SecurityIndicator security={security} />}
958
1063
  </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>
1064
+ {senderNameCheck.misleading && (
1065
+ <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
1066
+ The sender&rsquo;s name &ldquo;{senderName}&rdquo; looks like an email address, but this message was
1067
+ sent from <span className="font-medium">{senderAddress}</span>. Don&rsquo;t trust it based on the name.
1068
+ </p>
995
1069
  )}
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
1070
+ {/* Every recipient with their address, grouped as the sender addressed them; a long list folds. */}
1071
+ <RecipientLine label="To" recipients={message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")} />
1072
+ <RecipientLine label="Cc" recipients={message.recipients.filter((r) => r.type === "cc")} />
1073
+ <RecipientLine label="Bcc" recipients={message.recipients.filter((r) => r.type === "bcc")} />
1074
+ </div>
1075
+ <div className="ml-auto flex flex-wrap items-center justify-end gap-1">
1076
+ <time dateTime={message.receivedDate} className={["text-xs mr-1", dateClass(cardUnread)].join(" ")}>
1077
+ {new Date(message.receivedDate).toLocaleString()}
1078
+ </time>
1079
+ {adaptable && (
1080
+ <button
1002
1081
  type="button"
1003
- variant="secondary"
1004
- className="!w-auto shrink-0"
1005
- loading={canceling}
1006
- disabled={canceling}
1007
- onClick={handleCancelScheduledSend}
1082
+ aria-label="View original"
1083
+ aria-pressed={viewOriginal}
1084
+ title={viewOriginal ? "Follow the theme" : "View original"}
1085
+ onClick={() => setViewOriginal(!viewOriginal)}
1086
+ className={[
1087
+ "inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt",
1088
+ viewOriginal ? "bg-primary/10" : "",
1089
+ ].join(" ")}
1008
1090
  >
1009
- Move to Drafts
1010
- </Button>
1091
+ {viewOriginal ? <HiOutlineMoon size={16} aria-hidden="true" /> : <HiOutlineSun size={16} aria-hidden="true" />}
1092
+ </button>
1093
+ )}
1094
+ {/* Wraps: in a thread the pane can be as narrow as the reading pane gets (the list takes
1095
+ 384px of it), and these are six controls. */}
1096
+ <IconAction
1097
+ icon={<HiOutlineArrowUturnLeft size={16} aria-hidden="true" />}
1098
+ label="Reply"
1099
+ shortcut={keyboard ? SHORTCUTS.mail.reply : undefined}
1100
+ disabled={preparingCompose}
1101
+ onPrefetch={prefetchReply}
1102
+ onClick={() => void handleReplyOrForward("reply")}
1103
+ />
1104
+ <IconAction
1105
+ icon={
1106
+ // The conventional reply-all glyph: the reply arrow, doubled. `hi2` has no
1107
+ // reply-all icon of its own, and nothing else in it means "answer everyone".
1108
+ <span className="inline-flex items-center" aria-hidden="true">
1109
+ <HiOutlineArrowUturnLeft size={16} />
1110
+ <HiOutlineArrowUturnLeft size={16} className="-ml-2.5" />
1111
+ </span>
1112
+ }
1113
+ label="Reply All"
1114
+ shortcut={keyboard ? SHORTCUTS.mail.replyAll : undefined}
1115
+ disabled={preparingCompose}
1116
+ onPrefetch={prefetchReply}
1117
+ onClick={() => void handleReplyOrForward("replyAll")}
1118
+ />
1119
+ <IconAction
1120
+ icon={<HiOutlineArrowUturnRight size={16} aria-hidden="true" />}
1121
+ label="Forward"
1122
+ shortcut={keyboard ? SHORTCUTS.mail.forward : undefined}
1123
+ disabled={preparingCompose}
1124
+ onPrefetch={prefetchReply}
1125
+ onClick={() => void handleReplyOrForward("forward")}
1126
+ />
1127
+ {archivable && (
1128
+ <IconAction
1129
+ icon={<HiOutlineArchiveBox size={16} aria-hidden="true" />}
1130
+ label="Archive"
1131
+ shortcut={keyboard ? SHORTCUTS.mail.archive : undefined}
1132
+ busy={archiving}
1133
+ disabled={archiving}
1134
+ onClick={handleArchive}
1135
+ />
1136
+ )}
1137
+ {movable && (
1138
+ <IconAction
1139
+ icon={<HiOutlineFolderArrowDown size={16} aria-hidden="true" />}
1140
+ label="Move to"
1141
+ shortcut={keyboard ? SHORTCUTS.mail.move : undefined}
1142
+ onClick={() => setMovePrompt(true)}
1143
+ />
1144
+ )}
1145
+ {labels && labels.length > 0 && (
1146
+ <LabelMenuButton
1147
+ aria-label="Labels"
1148
+ label="Labels"
1149
+ className="border border-border py-1.5"
1150
+ labels={labels}
1151
+ mailboxUid={message.mailboxUid}
1152
+ onLabelCreated={onLabelCreated}
1153
+ applied={currentLabelsMessage.labelUids ?? []}
1154
+ onCommit={(labelUids) => void handleApplyLabels(labelUids)}
1155
+ busy={savingLabels}
1156
+ note="Ticked labels are applied to this message and unticked ones removed."
1157
+ emptyNote="This mailbox has no labels yet."
1158
+ commit={{ label: "Apply" }}
1159
+ clear={{ label: "Remove all labels" }}
1160
+ />
1011
1161
  )}
1012
1162
  </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>
1163
+ </div>
1164
+
1165
+ {/* A slim bar for what has been done with the message. */}
1166
+ {(answered || forwarded) && (
1167
+ <p className="px-4 py-1.5 text-xs text-text-muted bg-surface-alt border-y border-border">
1168
+ {answered && forwarded ? "You replied to and forwarded this message." : answered ? "You replied to this message." : "You forwarded this message."}
1169
+ </p>
1170
+ )}
1171
+
1172
+ <div id={threadHeader?.bodyId} className="px-4 pb-3 flex flex-col gap-2">
1173
+ {inThread && (subjectDiffers || !sameSubject(shownSubject, threadSubject)) && (
1174
+ <h3 className="text-sm font-semibold break-words">{shownSubject}</h3>
1022
1175
  )}
1176
+ <div className="flex flex-wrap items-center gap-2 empty:hidden">{sendingStatus}</div>
1177
+ {inOutbox && message.scheduledSendError && <Alert>This message wasn&rsquo;t sent: {message.scheduledSendError}</Alert>}
1178
+ {cancelError && <Alert>{cancelError}</Alert>}
1023
1179
  {keyChangeNotice && (
1024
- <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1180
+ <p role="status" className="py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1025
1181
  {keyChangeNotice}
1026
1182
  </p>
1027
1183
  )}
@@ -1029,7 +1185,7 @@ function MessageDetailContent({
1029
1185
  // Informational: the signature is intact, but nothing ties its certificate to this sender - anyone can
1030
1186
  // create a certificate naming any address. "Trust this signer" (below) pins it once the reader has
1031
1187
  // confirmed the fingerprint.
1032
- <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1188
+ <p role="status" className="py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1033
1189
  Signed, but the signer isn&rsquo;t a trusted contact key, so the sender isn&rsquo;t verified.
1034
1190
  {security.signerEmails && security.signerEmails.length > 0 && <> Certificate for {security.signerEmails.join(", ")}.</>}
1035
1191
  {security.signerFingerprint && (
@@ -1043,7 +1199,7 @@ function MessageDetailContent({
1043
1199
  {pendingConflict && (
1044
1200
  // Only an unverified signer's result loads the key state while the sender is unpinned, and a conflict
1045
1201
  // 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">
1202
+ <p role="status" className="py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
1047
1203
  This sender has a signing key change waiting for your review, so this signer can&rsquo;t be trusted from
1048
1204
  here.{" "}
1049
1205
  <a
@@ -1055,7 +1211,7 @@ function MessageDetailContent({
1055
1211
  </p>
1056
1212
  )}
1057
1213
  {trustableCertificate && (
1058
- <div className="mt-1.5">
1214
+ <div>
1059
1215
  <Button
1060
1216
  type="button"
1061
1217
  variant="secondary"
@@ -1071,33 +1227,31 @@ function MessageDetailContent({
1071
1227
  </div>
1072
1228
  )}
1073
1229
  {subjectDiffers && (
1074
- <p role="status" className="mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1230
+ <p role="status" className="py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1075
1231
  The subject shown above is the one the sender signed. It differs from the subject this message was
1076
1232
  delivered with (&ldquo;{message.subject}&rdquo;), which may have been changed on the way, e.g. by a
1077
1233
  mailing list.
1078
1234
  </p>
1079
1235
  )}
1080
1236
  {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>
1237
+ <Alert>
1238
+ This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or
1239
+ encrypted — an intermediary may have altered them after sending. Treat the fields shown above with
1240
+ caution.
1241
+ </Alert>
1088
1242
  )}
1089
1243
  {security?.state === "verified_at_first_open" && (
1090
1244
  // A seal outranks only a key-status failure (see `evaluateMessageSecurityWithSeal()`). A signer key
1091
1245
  // later reported compromised is a warning, never reassurance.
1092
1246
  <p
1093
1247
  role="status"
1094
- className={`mt-2 py-2 px-3 rounded-sm text-sm text-text ${security.laterCompromised ? "bg-warning/15" : "bg-surface-alt"}`}
1248
+ className={`py-2 px-3 rounded-sm text-sm text-text ${security.laterCompromised ? "bg-warning/15" : "bg-surface-alt"}`}
1095
1249
  >
1096
1250
  {verifiedAtFirstOpenMessage(security)}
1097
1251
  </p>
1098
1252
  )}
1099
1253
  {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">
1254
+ <section aria-label="Signing key changed" className="py-3 px-3 rounded-sm text-sm bg-warning/15 text-text flex flex-col gap-2">
1101
1255
  <h2 className="font-semibold">This sender&rsquo;s signing key changed</h2>
1102
1256
  <p>
1103
1257
  The signature on this message is valid and its certificate names {senderAddress}, but it was made
@@ -1127,7 +1281,7 @@ function MessageDetailContent({
1127
1281
  {security?.state === "signature_failed" && !keyChanged && (
1128
1282
  // Deliberately an informational notice, not an error `Alert`: an unverifiable signature
1129
1283
  // 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">
1284
+ <p role="status" className="py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1131
1285
  {security.signatureFailureReason
1132
1286
  ? SIGNATURE_FAILURE_MESSAGE[security.signatureFailureReason]
1133
1287
  : GENERIC_SIGNATURE_FAILURE_MESSAGE}
@@ -1135,114 +1289,25 @@ function MessageDetailContent({
1135
1289
  )}
1136
1290
  {verifiedOrSealed && !security.protectedHeaders && (
1137
1291
  // 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">
1292
+ <p role="status" className="py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1139
1293
  The signature covers this message&rsquo;s content and attachments only. Its Subject, To and Cc
1140
1294
  weren&rsquo;t signed, so they could have been changed after it was sent.
1141
1295
  </p>
1142
1296
  )}
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">
1297
+ {security?.notAddressedToReader && !isSentItems && (
1298
+ // Informational, like the notice above: a Bcc recipient legitimately sees this too. Never shown in Sent
1299
+ // Items: the sender's own address is (by design, see ComposeWindow/sendJob) never one of the protected
1300
+ // To/Cc headers of a message it sent to someone else, so this would otherwise fire on every ordinary sent
1301
+ // message rather than the genuine forwarded/re-sent/Bcc cases it's meant to flag.
1302
+ <p role="status" className="py-2 px-3 rounded-sm text-sm bg-surface-alt text-text">
1146
1303
  The recipients this message was signed or encrypted for don&rsquo;t include this mailbox - it may have
1147
1304
  been forwarded or re-sent to you unchanged, or you were Bcc&rsquo;d.
1148
1305
  </p>
1149
1306
  )}
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
- )}
1307
+ {archiveError && <Alert>{archiveError}</Alert>}
1308
+ {labelsError && <Alert>{labelsError}</Alert>}
1244
1309
  {labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (
1245
- <div className="flex flex-wrap items-center gap-1.5 mt-2">
1310
+ <div className="flex flex-wrap items-center gap-1.5">
1246
1311
  {currentLabelsMessage
1247
1312
  .labelUids!.map((uid) => labels.find((l) => l.uid === uid))
1248
1313
  .filter((l): l is Label => !!l)
@@ -1262,10 +1327,7 @@ function MessageDetailContent({
1262
1327
  {(["delivery", "read"] as const)
1263
1328
  .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
1264
1329
  .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
- >
1330
+ <div key={type} className="flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm">
1269
1331
  <span>
1270
1332
  {senderLabel} requested a {type} receipt for this
1271
1333
  message.
@@ -1280,30 +1342,21 @@ function MessageDetailContent({
1280
1342
  >
1281
1343
  Send receipt
1282
1344
  </Button>
1283
- <Button
1284
- type="button"
1285
- variant="text"
1286
- disabled={receiptBusy !== null}
1287
- onClick={() => handleReceipt(type, "decline")}
1288
- >
1345
+ <Button type="button" variant="text" disabled={receiptBusy !== null} onClick={() => handleReceipt(type, "decline")}>
1289
1346
  Decline
1290
1347
  </Button>
1291
1348
  </div>
1292
1349
  ))}
1293
- {receiptError && (
1294
- <div className="mt-2">
1295
- <Alert>{receiptError}</Alert>
1296
- </div>
1297
- )}
1350
+ {receiptError && <Alert>{receiptError}</Alert>}
1298
1351
  {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">
1352
+ <p role="status" className="py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
1300
1353
  These attachments come from a message whose signature couldn&rsquo;t be verified. Open them only if
1301
1354
  you trust the sender.
1302
1355
  </p>
1303
1356
  )}
1304
1357
  {innerAttachments
1305
1358
  ? innerAttachments.length > 0 && (
1306
- <ul className="flex flex-wrap gap-2 mt-3">
1359
+ <ul className="flex flex-wrap gap-2">
1307
1360
  {innerAttachments.map((attachment, index) => (
1308
1361
  <li key={`${index}:${attachment.filename ?? ""}`}>
1309
1362
  <button
@@ -1318,7 +1371,7 @@ function MessageDetailContent({
1318
1371
  </ul>
1319
1372
  )
1320
1373
  : attachments.length > 0 && (
1321
- <ul className="flex flex-wrap gap-2 mt-3">
1374
+ <ul className="flex flex-wrap gap-2">
1322
1375
  {attachments.map((attachment) => (
1323
1376
  <li key={attachment.uid}>
1324
1377
  <a
@@ -1331,50 +1384,76 @@ function MessageDetailContent({
1331
1384
  ))}
1332
1385
  </ul>
1333
1386
  )}
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>
1387
+ {/* The body: an isolated frame exactly as tall as its content (or text), in the card's own place. A decrypted or verified
1388
+ body never came through the server's own sanitizer (the server never saw the plaintext); `MessageBody` sanitizes every body
1389
+ client-side regardless, behind a no-remote-loads CSP, in a frame that runs no script. */}
1390
+ <div ref={bodyRegionRef} tabIndex={-1} className="outline-none">
1391
+ {message.encrypted && security === null ? (
1392
+ <BodySkeleton />
1393
+ ) : security?.decryptError !== undefined ? (
1394
+ // An encrypted message that could not be read: locked (no unlocked keys - the button asks the app's own unlock prompt, and the message
1395
+ // then decrypts in place) or genuinely unreadable (keys unlocked and it still didn't open - the reason, and nothing to click).
1396
+ <EncryptedBody locked={lockedRef.current} reason={security.decryptError} unlocking={unlocking} onUnlock={() => void handleUnlockToView()} />
1397
+ ) : (
1398
+ <MessageBody
1399
+ messageUid={message.uid}
1400
+ messageVersion={message.version}
1401
+ title={bodyTitle}
1402
+ content={bodyContent}
1403
+ attachments={attachments}
1404
+ inlineParts={innerAttachments}
1405
+ original={viewOriginal}
1406
+ onAdaptable={setAdaptable}
1407
+ />
1347
1408
  )}
1348
1409
  </div>
1410
+ </div>
1411
+
1412
+ {showFooter && (
1413
+ <div className="flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden">
1414
+ <Button
1415
+ type="button"
1416
+ variant="secondary"
1417
+ className="!w-auto"
1418
+ aria-label="Reply to this message"
1419
+ disabled={preparingCompose}
1420
+ onClick={() => void handleReplyOrForward("reply")}
1421
+ >
1422
+ Reply
1423
+ </Button>
1424
+ <Button
1425
+ type="button"
1426
+ variant="secondary"
1427
+ className="!w-auto"
1428
+ aria-label="Forward this message"
1429
+ disabled={preparingCompose}
1430
+ onClick={() => void handleReplyOrForward("forward")}
1431
+ >
1432
+ Forward
1433
+ </Button>
1434
+ </div>
1349
1435
  )}
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
- />
1436
+ </CardShell>
1437
+ );
1438
+
1439
+ return (
1440
+ // `h-full` outside a thread only: the standalone message route renders this pane straight into a
1441
+ // *block* (`MailShell`'s own `<main>`), where nothing stretches it and a percentage of that block's
1442
+ // own resolved height is the height. Inside a thread the card is one row of the thread's own list.
1443
+ <div className={inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full"}>
1444
+ {inThread ? (
1445
+ card
1370
1446
  ) : (
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
- />
1447
+ <>
1448
+ {backHref && (
1449
+ <a href={backHref} className="text-sm text-primary-dark hover:underline block px-4 pt-3">
1450
+ &larr; Back to messages
1451
+ </a>
1452
+ )}
1453
+ {/* The subject has a card of its own, above the message, that stays put while the message scrolls. */}
1454
+ <SubjectCard subject={shownSubject} />
1455
+ <div className="flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3">{card}</div>
1456
+ </>
1378
1457
  )}
1379
1458
 
1380
1459
  <MoveToFolderDialog