@rapidmx/web-client 0.5.0 → 0.7.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 (224) hide show
  1. package/README.md +88 -0
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +25 -8
  3. package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
  4. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
  5. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +159 -152
  6. package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
  7. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
  8. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
  9. package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
  10. package/apps/shared/components/calendar/layout/CalendarShell.tsx +194 -192
  11. package/apps/shared/components/contacts/layout/ContactsShell.tsx +199 -197
  12. package/apps/shared/components/layout/AppShell.tsx +288 -270
  13. package/apps/shared/components/mail/ConversationList.tsx +225 -33
  14. package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
  15. package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
  16. package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
  17. package/apps/shared/components/mail/MenuButton.tsx +384 -0
  18. package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
  19. package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
  20. package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
  21. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
  22. package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
  23. package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
  24. package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
  25. package/apps/shared/components/mail/compose/recipients.ts +104 -0
  26. package/apps/shared/components/mail/labelMenu.tsx +278 -0
  27. package/apps/shared/components/mail/layout/MailShell.tsx +31 -4
  28. package/apps/shared/components/mail/listPreferences.ts +228 -0
  29. package/apps/shared/components/settings/layout/SettingsShell.tsx +23 -10
  30. package/apps/shared/components/tasks/layout/TasksShell.tsx +201 -199
  31. package/apps/shared/plugins/pluginNav.ts +73 -0
  32. package/apps/shared/search/localIndexRpcClient.ts +3 -1
  33. package/apps/shared/styles/app.css +28 -5
  34. package/apps/www/index.tsx +1854 -1314
  35. package/apps/www/messages/[uid].tsx +5 -4
  36. package/dist/apps/admin/_404.d.ts +2 -0
  37. package/dist/apps/admin/_500.d.ts +2 -0
  38. package/dist/apps/admin/_layout.d.ts +8 -0
  39. package/dist/apps/admin/audit-log/index.d.ts +11 -0
  40. package/dist/apps/admin/branding/index.d.ts +3 -0
  41. package/dist/apps/admin/data-requests/index.d.ts +3 -0
  42. package/dist/apps/admin/distribution-lists/[uid].d.ts +7 -0
  43. package/dist/apps/admin/distribution-lists/index.d.ts +3 -0
  44. package/dist/apps/admin/distribution-lists/new/index.d.ts +3 -0
  45. package/dist/apps/admin/domains/[uid].d.ts +7 -0
  46. package/dist/apps/admin/domains/index.d.ts +3 -0
  47. package/dist/apps/admin/domains/new/index.d.ts +3 -0
  48. package/dist/apps/admin/encryption-policy/index.d.ts +3 -0
  49. package/dist/apps/admin/escrow-scopes/[uid].d.ts +21 -0
  50. package/dist/apps/admin/escrow-scopes/index.d.ts +3 -0
  51. package/dist/apps/admin/escrow-scopes/new/index.d.ts +3 -0
  52. package/dist/apps/admin/index.d.ts +3 -0
  53. package/dist/apps/admin/ingest-queue/index.d.ts +4 -0
  54. package/dist/apps/admin/mailbox-policy/index.d.ts +3 -0
  55. package/dist/apps/admin/mailboxes/[uid].d.ts +7 -0
  56. package/dist/apps/admin/mailboxes/new/index.d.ts +3 -0
  57. package/dist/apps/admin/plugins/index.d.ts +3 -0
  58. package/dist/apps/admin/quarantine/index.d.ts +6 -0
  59. package/dist/apps/admin/retention-policy/index.d.ts +3 -0
  60. package/dist/apps/admin/setup/index.d.ts +3 -0
  61. package/dist/apps/admin/transport-rules/[uid].d.ts +7 -0
  62. package/dist/apps/admin/transport-rules/_transportRuleConfig.d.ts +21 -0
  63. package/dist/apps/admin/transport-rules/index.d.ts +3 -0
  64. package/dist/apps/admin/transport-rules/new/index.d.ts +3 -0
  65. package/dist/apps/escrow/_layout.d.ts +10 -0
  66. package/dist/apps/escrow/audit-log/index.d.ts +9 -0
  67. package/dist/apps/escrow/index.d.ts +3 -0
  68. package/dist/apps/escrow/matters/[uid].d.ts +7 -0
  69. package/dist/apps/escrow/matters/new/index.d.ts +3 -0
  70. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.d.ts +17 -0
  71. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +32 -0
  72. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +32 -0
  73. package/dist/apps/shared/components/admin/layout/AdminShell.js +12 -5
  74. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.d.ts +12 -0
  75. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.d.ts +6 -0
  76. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.d.ts +19 -0
  77. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +12 -0
  78. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +13 -0
  79. package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
  80. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +12 -0
  81. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +15 -0
  82. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
  83. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.d.ts +9 -0
  84. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.d.ts +19 -0
  85. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +13 -0
  86. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +2 -2
  87. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +11 -0
  88. package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
  89. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +12 -0
  90. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
  91. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.d.ts +14 -0
  92. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
  93. package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +36 -0
  94. package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
  95. package/dist/apps/shared/components/admin/signOut.d.ts +23 -0
  96. package/dist/apps/shared/components/admin/usePagedList.d.ts +39 -0
  97. package/dist/apps/shared/components/calendar/CalendarListSidebar.d.ts +26 -0
  98. package/dist/apps/shared/components/calendar/EventModal.d.ts +46 -0
  99. package/dist/apps/shared/components/calendar/MonthView.d.ts +15 -0
  100. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +20 -0
  101. package/dist/apps/shared/components/calendar/ResourcePicker.d.ts +19 -0
  102. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +28 -0
  103. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +17 -0
  104. package/dist/apps/shared/components/calendar/allDay.d.ts +51 -0
  105. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +40 -0
  106. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
  107. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +23 -0
  108. package/dist/apps/shared/components/contacts/ContactForm.d.ts +20 -0
  109. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +40 -0
  110. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +25 -0
  111. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +40 -0
  112. package/dist/apps/shared/components/contacts/contactKeys.d.ts +29 -0
  113. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +21 -0
  114. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
  115. package/dist/apps/shared/components/escrow/layout/EscrowShell.d.ts +25 -0
  116. package/dist/apps/shared/components/forms/StringListField.d.ts +22 -0
  117. package/dist/apps/shared/components/layout/AppShell.d.ts +49 -0
  118. package/dist/apps/shared/components/layout/AppShell.js +15 -13
  119. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +18 -0
  120. package/dist/apps/shared/components/layout/KeyEnrollmentGate.d.ts +51 -0
  121. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +13 -0
  122. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +66 -0
  123. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +45 -0
  124. package/dist/apps/shared/components/layout/UserMenu.d.ts +23 -0
  125. package/dist/apps/shared/components/mail/ConversationList.d.ts +47 -0
  126. package/dist/apps/shared/components/mail/ConversationList.js +84 -10
  127. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +62 -0
  128. package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
  129. package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
  130. package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
  131. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
  132. package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
  133. package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
  134. package/dist/apps/shared/components/mail/MenuButton.js +211 -0
  135. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +101 -0
  136. package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
  137. package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
  138. package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
  139. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +73 -0
  140. package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
  141. package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +15 -0
  142. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +37 -0
  143. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
  144. package/dist/apps/shared/components/mail/compose/EmojiPicker.d.ts +14 -0
  145. package/dist/apps/shared/components/mail/compose/GifPicker.d.ts +13 -0
  146. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
  147. package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
  148. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +43 -0
  149. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
  150. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.d.ts +16 -0
  151. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +27 -0
  152. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
  153. package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
  154. package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
  155. package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
  156. package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
  157. package/dist/apps/shared/components/mail/labelMenu.js +134 -0
  158. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +65 -0
  159. package/dist/apps/shared/components/mail/layout/MailShell.js +17 -5
  160. package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
  161. package/dist/apps/shared/components/mail/listPreferences.js +186 -0
  162. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +33 -0
  163. package/dist/apps/shared/components/mail/verificationSeals.d.ts +22 -0
  164. package/dist/apps/shared/components/mail/writableMailboxes.d.ts +15 -0
  165. package/dist/apps/shared/components/rules/RuleBuilder.d.ts +70 -0
  166. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +41 -0
  167. package/dist/apps/shared/components/settings/layout/SettingsShell.js +19 -10
  168. package/dist/apps/shared/components/tasks/TasksSidebar.d.ts +41 -0
  169. package/dist/apps/shared/components/tasks/TasksToolbar.d.ts +14 -0
  170. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +24 -0
  171. package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
  172. package/dist/apps/shared/mail/findWellKnownFolderUid.d.ts +5 -0
  173. package/dist/apps/shared/mail/listAllPages.d.ts +17 -0
  174. package/dist/apps/shared/plugins/pluginNav.d.ts +39 -0
  175. package/dist/apps/shared/plugins/pluginNav.js +33 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +13 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.d.ts +36 -0
  178. package/dist/apps/shared/search/localIndexBuilder.d.ts +85 -0
  179. package/dist/apps/shared/search/localIndexKey.d.ts +4 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.d.ts +71 -0
  181. package/dist/apps/shared/search/localIndexRpcClient.js +3 -1
  182. package/dist/apps/shared/search/localIndexSchema.d.ts +111 -0
  183. package/dist/apps/shared/search/localIndexSizePreference.d.ts +27 -0
  184. package/dist/apps/shared/search/localIndexStorage.d.ts +28 -0
  185. package/dist/apps/shared/search/localIndexVFS.d.ts +101 -0
  186. package/dist/apps/shared/search/localIndexWorker.d.ts +145 -0
  187. package/dist/apps/shared/search/searchTier2.d.ts +30 -0
  188. package/dist/apps/shared/styles/app.css +28 -5
  189. package/dist/apps/www/_404.d.ts +2 -0
  190. package/dist/apps/www/_500.d.ts +2 -0
  191. package/dist/apps/www/_layout.d.ts +14 -0
  192. package/dist/apps/www/calendar/index.d.ts +3 -0
  193. package/dist/apps/www/contacts/[uid].d.ts +13 -0
  194. package/dist/apps/www/contacts/index.d.ts +3 -0
  195. package/dist/apps/www/index.d.ts +3 -0
  196. package/dist/apps/www/index.js +506 -112
  197. package/dist/apps/www/messages/[uid].d.ts +11 -0
  198. package/dist/apps/www/messages/[uid].js +2 -3
  199. package/dist/apps/www/settings/auto-reply/index.d.ts +4 -0
  200. package/dist/apps/www/settings/encryption/index.d.ts +7 -0
  201. package/dist/apps/www/settings/filters/[uid].d.ts +8 -0
  202. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.d.ts +11 -0
  203. package/dist/apps/www/settings/filters/index.d.ts +4 -0
  204. package/dist/apps/www/settings/filters/new/index.d.ts +4 -0
  205. package/dist/apps/www/settings/labels/index.d.ts +4 -0
  206. package/dist/apps/www/settings/privacy/index.d.ts +4 -0
  207. package/dist/apps/www/settings/read-receipts/index.d.ts +4 -0
  208. package/dist/apps/www/settings/sharing/index.d.ts +4 -0
  209. package/dist/apps/www/settings/signatures/[uid].d.ts +8 -0
  210. package/dist/apps/www/settings/signatures/index.d.ts +4 -0
  211. package/dist/apps/www/settings/signatures/new/index.d.ts +4 -0
  212. package/dist/apps/www/settings/signatures/signatureDefaults.d.ts +10 -0
  213. package/dist/apps/www/tasks/index.d.ts +3 -0
  214. package/package.json +3 -3
  215. package/apps/shared/components/booking/AvailabilityEditor.tsx +0 -118
  216. package/apps/www/settings/booking-types/[uid].tsx +0 -306
  217. package/apps/www/settings/booking-types/index.tsx +0 -98
  218. package/apps/www/settings/booking-types/new/index.tsx +0 -212
  219. package/apps/www/settings/focused-inbox/index.tsx +0 -150
  220. package/dist/apps/shared/components/booking/AvailabilityEditor.js +0 -45
  221. package/dist/apps/www/settings/booking-types/[uid].js +0 -134
  222. package/dist/apps/www/settings/booking-types/index.js +0 -29
  223. package/dist/apps/www/settings/booking-types/new/index.js +0 -86
  224. package/dist/apps/www/settings/focused-inbox/index.js +0 -71
@@ -3,27 +3,45 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useEffect, useRef, useState } from "react";
6
- import { HiOutlineCheck, HiOutlineExclamationTriangle, HiOutlineLockClosed } from "react-icons/hi2";
7
- import DOMPurify from "dompurify";
6
+ import {
7
+ HiOutlineArchiveBox,
8
+ HiOutlineArrowUturnLeft,
9
+ HiOutlineArrowUturnRight,
10
+ HiOutlineCheck,
11
+ HiOutlineExclamationTriangle,
12
+ HiOutlineFolderArrowDown,
13
+ HiOutlineLockClosed,
14
+ } from "react-icons/hi2";
8
15
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
16
  import {
10
17
  Attachment,
18
+ Folder,
11
19
  Message,
12
- MessageClassification,
13
20
  ReceiptType,
21
+ Recipient,
14
22
  approveReceipt,
15
23
  archiveMessage,
16
24
  attachmentContentUrl,
17
25
  cancelScheduledSend,
18
- classifyMessage,
19
26
  declineReceipt,
27
+ getMailbox,
20
28
  getMessage,
21
29
  getMessageRawContent,
30
+ moveMessage,
22
31
  recallMessage,
23
32
  setMessageLabels,
24
33
  } from "@rapidmx/react-shared/mail/mailApi.js";
25
34
  import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
26
- import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.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";
27
45
  import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
28
46
  import {
29
47
  MessageSecurityResult,
@@ -40,11 +58,14 @@ import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.
40
58
  import KeyChangeReview from "../contacts/KeyChangeReview.js";
41
59
  import { KEY_CHANGE_STALE_MESSAGE, sameFingerprint } from "../contacts/contactKeys.js";
42
60
  import { useCompose } from "./compose/ComposeContext.js";
61
+ import { loadOriginalMessage } from "./compose/quotedBody.js";
62
+ import { formatRecipient } from "./compose/recipients.js";
43
63
  import { useMailShell } from "./layout/MailShell.js";
44
64
  import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
45
65
  import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
46
66
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
47
67
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
68
+ import LabelMenuButton from "./labelMenu.js";
48
69
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
49
70
 
50
71
  /** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
@@ -155,6 +176,44 @@ function downloadMimeAttachment(attachment: MimeAttachment): void {
155
176
  setTimeout(() => URL.revokeObjectURL(url), 60_000);
156
177
  }
157
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
+
158
217
  function SecurityIndicator({ security }: { security: MessageSecurityResult }) {
159
218
  const { label, className } = SECURITY_INDICATOR[security.state];
160
219
  if (security.state !== "verified_at_first_open") {
@@ -202,58 +261,10 @@ const ENCRYPTED_LOAD_ERROR = "Couldn't load this message's encrypted content.";
202
261
  const BODY_CSP_META =
203
262
  "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; img-src data: cid:; style-src 'unsafe-inline'\">";
204
263
 
205
- const EMBEDDED_URI = /^\s*(?:data|cid):/i;
206
- /** Attributes whose value makes the browser fetch a resource (as opposed to a user-clicked link). */
207
- const RESOURCE_URI_ATTRIBUTES = new Set(["src", "srcset", "background", "poster", "lowsrc", "dynsrc", "xlink:href", "action", "formaction"]);
208
-
209
- /** Decodes CSS escapes first (so an escaped `u\72l(` can't hide from the checks below), then drops every
210
- * `@import` and neutralizes every `url()`/`image-set()` reference that isn't a `data:`/`cid:` URI. */
211
- export function stripRemoteCssUrls(css: string): string {
212
- return css
213
- .replace(/\\([0-9a-f]{1,6})\s?/gi, (_match, hex: string) => String.fromCodePoint(Math.min(parseInt(hex, 16), 0x10ffff)))
214
- .replace(/\\(.)/g, "$1")
215
- .replace(/@import[^;]*;?/gi, "")
216
- .replace(/(?:-webkit-)?image-set\((?:[^()]|\([^()]*\))*\)/gi, (match) =>
217
- [...match.matchAll(/(["'])(.*?)\1/g)].every(([, , uri]) => EMBEDDED_URI.test(uri)) ? match : "none",
218
- )
219
- .replace(/url\(\s*(["']?)(.*?)\1\s*\)/gi, (match, _quote: string, uri: string) => (EMBEDDED_URI.test(uri) ? match : "none"));
220
- }
221
-
222
- let bodyPurifier: ReturnType<typeof DOMPurify> | undefined;
223
-
224
- /** A dedicated DOMPurify instance (hooks registered here never leak into any other DOMPurify caller)
225
- * that additionally strips every remote resource reference - see `stripRemoteCssUrls()`. */
226
- function getBodyPurifier(): ReturnType<typeof DOMPurify> {
227
- if (!bodyPurifier) {
228
- bodyPurifier = DOMPurify(window);
229
- bodyPurifier.addHook("uponSanitizeElement", (node, data) => {
230
- if (data.tagName === "style") {
231
- // An element's `textContent` is always a string (only documents/doctypes yield null).
232
- node.textContent = stripRemoteCssUrls(node.textContent as string);
233
- }
234
- });
235
- bodyPurifier.addHook("uponSanitizeAttribute", (node, data) => {
236
- const name = data.attrName.toLowerCase();
237
- if (name === "style") {
238
- data.attrValue = stripRemoteCssUrls(data.attrValue);
239
- return;
240
- }
241
- const isNavigationLink = name === "href" && ["a", "area"].includes(node.nodeName.toLowerCase());
242
- if (RESOURCE_URI_ATTRIBUTES.has(name) || (name === "href" && !isNavigationLink)) {
243
- const candidates = name === "srcset" ? data.attrValue.split(/,\s+/) : [data.attrValue];
244
- if (!candidates.every((candidate) => EMBEDDED_URI.test(candidate))) {
245
- data.keepAttr = false;
246
- }
247
- }
248
- });
249
- }
250
- return bodyPurifier;
251
- }
252
-
253
- /** Sanitizes a client-rendered body for `srcDoc`, with the CSP meta as its very first element. */
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. */
254
266
  export function buildSecureSrcDoc(html: string): string {
255
- const sanitized = getBodyPurifier().sanitize(html, { FORBID_TAGS: ["link", "meta", "base"] });
256
- return BODY_CSP_META + sanitized;
267
+ return BODY_CSP_META + sanitizeMessageBodyHtml(html);
257
268
  }
258
269
 
259
270
  export interface MessageDetailPaneProps {
@@ -282,18 +293,21 @@ export interface MessageDetailPaneProps {
282
293
  /** Called with the server's updated copy (now back in Drafts, `scheduledSendTime` cleared) after
283
294
  * successfully canceling a scheduled send. */
284
295
  onScheduledSendCanceled?: (updated: Message) => void;
285
- /** Whether `message` currently lives in the Inbox — Focused/Other classification is an Inbox-only
286
- * concept (`FocusedInboxUtils.classifyMessage()` short-circuits to Focused for every other folder), so
287
- * the "Move to Other"/"Move to Focused" control below only renders here, the same
288
- * each-caller-computes-its-own-folder-type pattern `isSentItems`/`isOutbox` already use. */
289
- isInbox?: boolean;
290
- /** Called with the server's updated copy (carrying the new `inferenceClassification`) after a
291
- * successful classify — mirrors `onRecalled`'s identical shape. */
292
- onClassified?: (updated: Message) => void;
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;
293
307
  /** Called with the server's updated copy after approving/declining a pending delivery/read receipt —
294
- * mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`/
295
- * `isInbox`): `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are
296
- * only ever `true` on a real delivered copy, so the banner below is self-gating. */
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. */
297
311
  onReceiptHandled?: (updated: Message) => void;
298
312
  /** Called with the server's updated copy (now filed under the mailbox's Archive folder) after a
299
313
  * successful archive — mirrors `onRecalled`'s identical shape. Archiving itself is offered for any
@@ -309,6 +323,13 @@ export interface MessageDetailPaneProps {
309
323
  * a label — always patches in place, never removes from a caller's list (unlike `onArchived`):
310
324
  * changing labels never moves a message between folders. */
311
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;
312
333
  }
313
334
 
314
335
  function formatBytes(bytes: number): string {
@@ -320,9 +341,9 @@ function formatBytes(bytes: number): string {
320
341
  /**
321
342
  * A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
322
343
  * Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
323
- * the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
324
- * a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
325
- * site for how `message`/`attachments`/`isSentItems` are sourced.
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.
326
347
  */
327
348
  export default function MessageDetailPane(props: MessageDetailPaneProps) {
328
349
  if (!props.message) {
@@ -351,12 +372,15 @@ function MessageDetailContent({
351
372
  isOutbox,
352
373
  draftsFolderUid,
353
374
  onScheduledSendCanceled,
354
- isInbox,
355
- onClassified,
375
+ folders,
376
+ onMoved,
377
+ onFolderCreated,
356
378
  onReceiptHandled,
357
379
  onArchived,
358
380
  labels,
359
381
  onLabelsChanged,
382
+ onLabelCreated,
383
+ inThread,
360
384
  }: MessageDetailPaneProps & { message: Message }) {
361
385
  // A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
362
386
  // `reloadMessage()`. A prop copy newer by `version` wins; an equal-version reload is kept, since claiming a send
@@ -371,16 +395,13 @@ function MessageDetailContent({
371
395
  const [canceling, setCanceling] = useState(false);
372
396
  const [archiving, setArchiving] = useState(false);
373
397
  const [archiveError, setArchiveError] = useState<string | null>(null);
374
- const [labelsOpen, setLabelsOpen] = useState(false);
375
- const [togglingLabelUid, setTogglingLabelUid] = useState<string | null>(null);
398
+ const [savingLabels, setSavingLabels] = useState(false);
376
399
  const [labelsError, setLabelsError] = useState<string | null>(null);
377
400
  const [error, setError] = useState<string | null>(null);
378
401
  // Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
379
402
  // pane rather than inside a confirmation modal — the two flows never need to share one message.
380
403
  const [cancelError, setCancelError] = useState<string | null>(null);
381
- const [classifying, setClassifying] = useState(false);
382
- const [classifyError, setClassifyError] = useState<string | null>(null);
383
- const [alwaysForSender, setAlwaysForSender] = useState(false);
404
+ const [movePrompt, setMovePrompt] = useState(false);
384
405
  // Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
385
406
  // `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
386
407
  // wouldn't distinguish which row's buttons should show a loading state.
@@ -613,40 +634,65 @@ function MessageDetailContent({
613
634
  setUnlockRefresh((n) => n + 1);
614
635
  }
615
636
 
616
- // Only ever invoked from the Reply/Reply All/Forward buttons below, which themselves only render
617
- // once `message` is loaded (the early return above covers the only other state) — the non-null
618
- // assertions reflect that real invariant, matching `handleRecall`'s identical pattern just below.
619
- function handleReply() {
620
- openCompose({
621
- mailboxUid: message.mailboxUid,
622
- to: message.from.address,
623
- subject: replySubject(message.subject),
624
- quotedHtml: buildReplyQuote(message),
625
- signatureContext: "reply_forward",
626
- suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
627
- });
628
- }
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);
629
641
 
630
- function handleReplyAll() {
631
- const cc = message.recipients.filter((r) => r.type !== "bcc").map((r) => r.address);
632
- openCompose({
633
- mailboxUid: message.mailboxUid,
634
- to: message.from.address,
635
- cc: cc.join(", "),
636
- subject: replySubject(message.subject),
637
- quotedHtml: buildReplyQuote(message),
638
- signatureContext: "reply_forward",
639
- suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
640
- });
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 ?? [])] : [];
641
647
  }
642
648
 
643
- function handleForward() {
644
- openCompose({
645
- mailboxUid: message.mailboxUid,
646
- subject: forwardSubject(message.subject),
647
- quotedHtml: buildForwardQuote(message),
648
- signatureContext: "reply_forward",
649
- });
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
+ }
650
696
  }
651
697
 
652
698
  async function handleRecall() {
@@ -709,50 +755,41 @@ function MessageDetailContent({
709
755
  }
710
756
  }
711
757
 
712
- // Only ever invoked from a checkbox in the Labels popover below, which itself only renders once
713
- // `message` is loaded — auto-saves on every toggle (no separate "Save" step), computing the full
714
- // new `labelUids` set from the message's current one since `setMessageLabels()` replaces the whole
715
- // list rather than patching a single entry.
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.
716
762
  //
717
- // Toggles are serialized (every checkbox is disabled while one is in flight - React flushes a
718
- // discrete input event's state update synchronously, so a second change can't slip in first), and
719
- // each one reads the latest known copy (`latestLabelsMessageRef`, never a stale render closure) - so
720
- // two quick toggles can never compute their full list, or send their optimistic-lock `version`, from
721
- // the same stale snapshot.
722
- async function handleToggleLabel(labelUid: string) {
723
- setTogglingLabelUid(labelUid);
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);
724
767
  setLabelsError(null);
725
768
  try {
726
- // Read through the ref (not this render's closure) so a toggle fired from a not-yet-re-rendered
727
- // handler still sees the previous toggle's server response.
728
769
  const base = message.version >= latestLabelsMessageRef.current.version ? message : latestLabelsMessageRef.current;
729
- const current = base.labelUids ?? [];
730
- const next = current.includes(labelUid) ? current.filter((uid) => uid !== labelUid) : [...current, labelUid];
731
- const updated = await setMessageLabels(base, next);
770
+ const updated = await setMessageLabels(base, labelUids);
732
771
  latestLabelsMessageRef.current = updated;
733
772
  setLabelsMessage(updated);
734
773
  onLabelsChanged?.(updated);
735
774
  } catch (err) {
736
775
  setLabelsError(err instanceof ApiRequestError ? err.message : "Could not update this message's labels.");
737
776
  } finally {
738
- setTogglingLabelUid(null);
777
+ setSavingLabels(false);
739
778
  }
740
779
  }
741
780
 
742
- // Only ever invoked from the classification button below, which itself only renders once `message`
743
- // is loaded and `isInbox` is true — the non-null assertion reflects the same real invariant as
744
- // `handleRecall`/`handleCancelScheduledSend` above.
745
- async function handleClassify(classifyAs: MessageClassification) {
746
- setClassifying(true);
747
- setClassifyError(null);
748
- try {
749
- const updated = await classifyMessage(message.uid, classifyAs, alwaysForSender);
750
- onClassified?.(updated);
751
- } catch (err) {
752
- setClassifyError(err instanceof ApiRequestError ? err.message : "Could not reclassify this message.");
753
- } finally {
754
- setClassifying(false);
755
- }
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);
756
793
  }
757
794
 
758
795
  // Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
@@ -813,8 +850,31 @@ function MessageDetailContent({
813
850
  ? senderKeyState!.conflict
814
851
  : undefined;
815
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
+
816
871
  return (
817
- <div className="flex-1 min-w-0 flex flex-col">
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"}`}>
818
878
  <div className="border-b border-border p-4">
819
879
  {backHref && (
820
880
  <a href={backHref} className="text-sm text-primary-dark hover:underline block mb-2">
@@ -823,7 +883,12 @@ function MessageDetailContent({
823
883
  )}
824
884
  <div className="flex items-start justify-between gap-3">
825
885
  <div className="flex items-center gap-2 min-w-0">
826
- <h1 className="text-lg font-bold tracking-tight truncate">{(protectedSubject ?? message.subject) || "(no subject)"}</h1>
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
+ )}
827
892
  {security && <SecurityIndicator security={security} />}
828
893
  </div>
829
894
  {sendInProgress && (
@@ -1029,32 +1094,66 @@ function MessageDetailContent({
1029
1094
  <p className="text-sm text-text-muted">
1030
1095
  To {message.recipients.map((r) => r.displayName || r.address).join(", ")}
1031
1096
  </p>
1032
- <div className="flex gap-2 mt-3">
1033
- <Button type="button" variant="secondary" className="!w-auto" onClick={handleReply}>
1034
- Reply
1035
- </Button>
1036
- <Button type="button" variant="secondary" className="!w-auto" onClick={handleReplyAll}>
1037
- Reply All
1038
- </Button>
1039
- <Button type="button" variant="secondary" className="!w-auto" onClick={handleForward}>
1040
- Forward
1041
- </Button>
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
+ />
1042
1125
  {!inOutbox && message.folderUid !== draftsFolderUid && (
1043
- <Button
1044
- type="button"
1045
- variant="secondary"
1046
- className="!w-auto"
1047
- loading={archiving}
1126
+ <IconAction
1127
+ icon={<HiOutlineArchiveBox size={16} aria-hidden="true" />}
1128
+ label="Archive"
1129
+ busy={archiving}
1048
1130
  disabled={archiving}
1049
1131
  onClick={handleArchive}
1050
- >
1051
- Archive
1052
- </Button>
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
+ />
1053
1140
  )}
1054
1141
  {labels && labels.length > 0 && (
1055
- <Button type="button" variant="secondary" className="!w-auto" onClick={() => setLabelsOpen(true)}>
1056
- Labels
1057
- </Button>
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
+ />
1058
1157
  )}
1059
1158
  </div>
1060
1159
  {archiveError && (
@@ -1062,6 +1161,11 @@ function MessageDetailContent({
1062
1161
  <Alert>{archiveError}</Alert>
1063
1162
  </div>
1064
1163
  )}
1164
+ {labelsError && (
1165
+ <div className="mt-2">
1166
+ <Alert>{labelsError}</Alert>
1167
+ </div>
1168
+ )}
1065
1169
  {labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (
1066
1170
  <div className="flex flex-wrap items-center gap-1.5 mt-2">
1067
1171
  {currentLabelsMessage
@@ -1078,34 +1182,8 @@ function MessageDetailContent({
1078
1182
  ))}
1079
1183
  </div>
1080
1184
  )}
1081
- {isInbox && (
1082
- <div className="flex flex-wrap items-center gap-2 mt-2">
1083
- <Button
1084
- type="button"
1085
- variant="text"
1086
- loading={classifying}
1087
- disabled={classifying}
1088
- onClick={() =>
1089
- handleClassify(message.inferenceClassification === "other" ? "focused" : "other")
1090
- }
1091
- >
1092
- {message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other"}
1093
- </Button>
1094
- <label className="flex items-center gap-1.5 text-xs text-text-muted">
1095
- <input
1096
- type="checkbox"
1097
- checked={alwaysForSender}
1098
- onChange={(e) => setAlwaysForSender(e.target.checked)}
1099
- />
1100
- Always for this sender
1101
- </label>
1102
- </div>
1103
- )}
1104
- {classifyError && (
1105
- <div className="mt-2">
1106
- <Alert>{classifyError}</Alert>
1107
- </div>
1108
- )}
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. */}
1109
1187
  {(["delivery", "read"] as const)
1110
1188
  .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
1111
1189
  .map((type) => (
@@ -1198,7 +1276,7 @@ function MessageDetailContent({
1198
1276
  // A recovered text/plain body renders as text (React escapes it) - never as markup.
1199
1277
  <pre
1200
1278
  aria-label={message.subject || "Message content"}
1201
- className="flex-1 w-full overflow-auto p-4 m-0 text-sm font-sans whitespace-pre-wrap break-words"
1279
+ className={`${bodyClassName} overflow-auto p-4 m-0 text-sm font-sans whitespace-pre-wrap break-words`}
1202
1280
  >
1203
1281
  {security.text}
1204
1282
  </pre>
@@ -1212,7 +1290,7 @@ function MessageDetailContent({
1212
1290
  title={message.subject || "Message content"}
1213
1291
  srcDoc={buildSecureSrcDoc(security.html)}
1214
1292
  sandbox=""
1215
- className="flex-1 w-full border-0"
1293
+ className={`${bodyClassName} border-0`}
1216
1294
  />
1217
1295
  ) : (
1218
1296
  <iframe
@@ -1220,10 +1298,19 @@ function MessageDetailContent({
1220
1298
  title={message.subject || "Message content"}
1221
1299
  src={`/api/mail/messages/${encodeURIComponent(message.uid)}/content`}
1222
1300
  sandbox=""
1223
- className="flex-1 w-full border-0"
1301
+ className={`${bodyClassName} border-0`}
1224
1302
  />
1225
1303
  )}
1226
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
+ />
1227
1314
  <Modal open={confirming} onClose={() => setConfirming(false)} title="Recall this message?">
1228
1315
  <p className="text-sm text-text-muted mb-4">
1229
1316
  This asks every original recipient's mail system to delete their copy, but only if it's
@@ -1291,28 +1378,6 @@ function MessageDetailContent({
1291
1378
  </div>
1292
1379
  </div>
1293
1380
  </Modal>
1294
- <Modal open={labelsOpen} onClose={() => setLabelsOpen(false)} title="Labels">
1295
- {labelsError && <Alert>{labelsError}</Alert>}
1296
- <ul className="flex flex-col gap-1">
1297
- {(labels ?? []).map((l) => {
1298
- const checked = currentLabelsMessage.labelUids?.includes(l.uid) ?? false;
1299
- return (
1300
- <li key={l.uid}>
1301
- <label className="flex items-center gap-2 py-1.5 px-1 text-sm rounded-sm hover:bg-surface-alt">
1302
- <input
1303
- type="checkbox"
1304
- checked={checked}
1305
- disabled={togglingLabelUid !== null}
1306
- onChange={() => handleToggleLabel(l.uid)}
1307
- />
1308
- <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: l.color ?? "#6366f1" }} />
1309
- {l.name}
1310
- </label>
1311
- </li>
1312
- );
1313
- })}
1314
- </ul>
1315
- </Modal>
1316
1381
  </div>
1317
1382
  );
1318
1383
  }