@rapidmx/web-client 0.6.0 → 0.8.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 (79) hide show
  1. package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
  2. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
  3. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -152
  4. package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
  5. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
  6. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
  7. package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
  8. package/apps/shared/components/layout/AppShell.tsx +288 -288
  9. package/apps/shared/components/mail/ConversationList.tsx +225 -33
  10. package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
  11. package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
  12. package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
  13. package/apps/shared/components/mail/MenuButton.tsx +384 -0
  14. package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
  15. package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
  16. package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
  17. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
  18. package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
  19. package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
  20. package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
  21. package/apps/shared/components/mail/compose/recipients.ts +104 -0
  22. package/apps/shared/components/mail/labelMenu.tsx +278 -0
  23. package/apps/shared/components/mail/layout/MailShell.tsx +445 -420
  24. package/apps/shared/components/mail/listPreferences.ts +228 -0
  25. package/apps/shared/components/settings/layout/SettingsShell.tsx +0 -1
  26. package/apps/shared/styles/app.css +28 -5
  27. package/apps/www/index.tsx +1854 -1314
  28. package/apps/www/messages/[uid].tsx +5 -4
  29. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +7 -3
  30. package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
  31. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +4 -1
  32. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
  33. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +9 -2
  34. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +20 -4
  35. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +8 -1
  36. package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
  37. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +4 -1
  38. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
  39. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
  40. package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
  41. package/dist/apps/shared/components/mail/ConversationList.d.ts +42 -6
  42. package/dist/apps/shared/components/mail/ConversationList.js +84 -10
  43. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +52 -13
  44. package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
  45. package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
  46. package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
  47. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
  48. package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
  49. package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
  50. package/dist/apps/shared/components/mail/MenuButton.js +211 -0
  51. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +27 -19
  52. package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
  53. package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
  54. package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
  55. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +16 -1
  56. package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
  57. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
  58. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
  59. package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
  60. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +10 -1
  61. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
  62. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
  63. package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
  64. package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
  65. package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
  66. package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
  67. package/dist/apps/shared/components/mail/labelMenu.js +134 -0
  68. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +8 -0
  69. package/dist/apps/shared/components/mail/layout/MailShell.js +15 -3
  70. package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
  71. package/dist/apps/shared/components/mail/listPreferences.js +186 -0
  72. package/dist/apps/shared/components/settings/layout/SettingsShell.js +0 -1
  73. package/dist/apps/shared/styles/app.css +28 -5
  74. package/dist/apps/www/index.js +506 -112
  75. package/dist/apps/www/messages/[uid].js +2 -3
  76. package/package.json +2 -2
  77. package/apps/www/settings/focused-inbox/index.tsx +0 -150
  78. package/dist/apps/www/settings/focused-inbox/index.d.ts +0 -4
  79. package/dist/apps/www/settings/focused-inbox/index.js +0 -71
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Attachment, Message } from "@rapidmx/react-shared/mail/mailApi.js";
2
+ import { Attachment, Folder, Message } from "@rapidmx/react-shared/mail/mailApi.js";
3
3
  import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
4
4
  import { MessageSecurityResult } from "@rapidmx/react-shared/crypto/messageSecurity.js";
5
5
  /** The detail line under a `verified_at_first_open` badge. */
@@ -24,10 +24,8 @@ export declare function trustSignerErrorMessage(err: unknown): string;
24
24
  /** Groups a fingerprint into 4-character blocks (`AB12 CD34 ...`), ignoring any separators it came with, so it can be
25
25
  * read out and compared with the sender over another channel. */
26
26
  export declare function formatFingerprint(fingerprint: string): string;
27
- /** Decodes CSS escapes first (so an escaped `u\72l(` can't hide from the checks below), then drops every
28
- * `@import` and neutralizes every `url()`/`image-set()` reference that isn't a `data:`/`cid:` URI. */
29
- export declare function stripRemoteCssUrls(css: string): string;
30
- /** Sanitizes a client-rendered body for `srcDoc`, with the CSP meta as its very first element. */
27
+ /** Sanitizes a client-rendered body for `srcDoc` (DOMPurify plus every remote resource reference stripped - see
28
+ * react-shared's `messageBodySanitizer.ts`), with the CSP meta as its very first element. */
31
29
  export declare function buildSecureSrcDoc(html: string): string;
32
30
  export interface MessageDetailPaneProps {
33
31
  message: Message | null;
@@ -55,18 +53,21 @@ export interface MessageDetailPaneProps {
55
53
  /** Called with the server's updated copy (now back in Drafts, `scheduledSendTime` cleared) after
56
54
  * successfully canceling a scheduled send. */
57
55
  onScheduledSendCanceled?: (updated: Message) => void;
58
- /** Whether `message` currently lives in the Inbox — Focused/Other classification is an Inbox-only
59
- * concept (`FocusedInboxUtils.classifyMessage()` short-circuits to Focused for every other folder), so
60
- * the "Move to Other"/"Move to Focused" control below only renders here, the same
61
- * each-caller-computes-its-own-folder-type pattern `isSentItems`/`isOutbox` already use. */
62
- isInbox?: boolean;
63
- /** Called with the server's updated copy (carrying the new `inferenceClassification`) after a
64
- * successful classify — mirrors `onRecalled`'s identical shape. */
65
- onClassified?: (updated: Message) => void;
56
+ /** Every folder of this message's own mailbox, for the Move to prompt's destination list. Absent or
57
+ * empty simply hides the control — there is nowhere to move to. Each caller already resolves this to
58
+ * work out `isSentItems`/`isOutbox`/`draftsFolderUid`, so nothing new is fetched for it. */
59
+ folders?: Folder[];
60
+ /** Called with the server's updated copy (now in the chosen folder) after a successful Move to. A move
61
+ * takes the message out of whichever folder is being listed, so callers remove it from their list
62
+ * rather than patching it — unlike `onClassified`, which this replaces. */
63
+ onMoved?: (updated: Message) => void;
64
+ /** A folder created from the Move to prompt, so the caller's own list and the folder sidebar pick it up
65
+ * without a page load. */
66
+ onFolderCreated?: (folder: Folder) => void;
66
67
  /** Called with the server's updated copy after approving/declining a pending delivery/read receipt —
67
- * mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`/
68
- * `isInbox`): `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are
69
- * only ever `true` on a real delivered copy, so the banner below is self-gating. */
68
+ * mirrors `onRecalled`'s identical shape. No gating prop needed (unlike `isSentItems`/`isOutbox`):
69
+ * `deliveryReceiptPending`/`readReceiptPending` already live directly on `message` and are only ever
70
+ * `true` on a real delivered copy, so the banner below is self-gating. */
70
71
  onReceiptHandled?: (updated: Message) => void;
71
72
  /** Called with the server's updated copy (now filed under the mailbox's Archive folder) after a
72
73
  * successful archive — mirrors `onRecalled`'s identical shape. Archiving itself is offered for any
@@ -82,12 +83,19 @@ export interface MessageDetailPaneProps {
82
83
  * a label — always patches in place, never removes from a caller's list (unlike `onArchived`):
83
84
  * changing labels never moves a message between folders. */
84
85
  onLabelsChanged?: (updated: Message) => void;
86
+ /** Handed a label created from the Labels menu, for the caller to add to `labels`. Without it the menu
87
+ * offers no "New label" row, only the link to where labels are managed. */
88
+ onLabelCreated?: (label: Label) => void;
89
+ /** Rendered as one message of a thread (`ConversationThreadPane`) rather than as the pane itself: the
90
+ * subject becomes a heading under the thread's own, since a document has one `h1` and the thread's is
91
+ * the conversation. Everything else - the badges, the actions, the body - is identical. */
92
+ inThread?: boolean;
85
93
  }
86
94
  /**
87
95
  * A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
88
96
  * Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
89
- * the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
90
- * a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
91
- * site for how `message`/`attachments`/`isSentItems` are sourced.
97
+ * and the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
98
+ * a message row) — see each call site for how `message`/`attachments`/`isSentItems` are sourced. The
99
+ * desktop pane shows whichever message the list opened, including one opened from a conversation row.
92
100
  */
93
101
  export default function MessageDetailPane(props: MessageDetailPaneProps): React.JSX.Element;
@@ -4,11 +4,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useRef, useState } from "react";
7
- import { HiOutlineCheck, HiOutlineExclamationTriangle, HiOutlineLockClosed } from "react-icons/hi2";
8
- import DOMPurify from "dompurify";
7
+ import { HiOutlineArchiveBox, HiOutlineArrowUturnLeft, HiOutlineArrowUturnRight, HiOutlineCheck, HiOutlineExclamationTriangle, HiOutlineFolderArrowDown, HiOutlineLockClosed, } from "react-icons/hi2";
9
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
10
- import { approveReceipt, archiveMessage, attachmentContentUrl, cancelScheduledSend, classifyMessage, declineReceipt, getMessage, getMessageRawContent, recallMessage, setMessageLabels, } from "@rapidmx/react-shared/mail/mailApi.js";
11
- import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
9
+ import { approveReceipt, archiveMessage, attachmentContentUrl, cancelScheduledSend, declineReceipt, getMailbox, getMessage, getMessageRawContent, moveMessage, recallMessage, setMessageLabels, } from "@rapidmx/react-shared/mail/mailApi.js";
10
+ import { buildForwardQuote, buildReplyQuote, buildReplyRecipients, buildReplyThreading, forwardSubject, replySubject, } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
11
+ import { sanitizeMessageBodyHtml } from "@rapidmx/react-shared/mail/messageBodySanitizer.js";
12
+ import MoveToFolderDialog from "./MoveToFolderDialog.js";
12
13
  import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
13
14
  import { evaluateMessageSecurity, evaluateMessageSecurityWithSeal, } from "@rapidmx/react-shared/crypto/messageSecurity.js";
14
15
  import { extractAddresses } from "@rapidmx/react-shared/crypto/mime.js";
@@ -20,11 +21,14 @@ import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.
20
21
  import KeyChangeReview from "../contacts/KeyChangeReview.js";
21
22
  import { KEY_CHANGE_STALE_MESSAGE, sameFingerprint } from "../contacts/contactKeys.js";
22
23
  import { useCompose } from "./compose/ComposeContext.js";
24
+ import { loadOriginalMessage } from "./compose/quotedBody.js";
25
+ import { formatRecipient } from "./compose/recipients.js";
23
26
  import { useMailShell } from "./layout/MailShell.js";
24
27
  import { useUnlockPrompt } from "../layout/UnlockPromptProvider.js";
25
28
  import { moveLocalEntity } from "../../search/localIndexRpcClient.js";
26
29
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
27
30
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
31
+ import LabelMenuButton from "./labelMenu.js";
28
32
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
29
33
  /** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
30
34
  * plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
@@ -115,6 +119,19 @@ function downloadMimeAttachment(attachment) {
115
119
  // Some browsers are still reading the blob after `click()` returns.
116
120
  setTimeout(() => URL.revokeObjectURL(url), 60000);
117
121
  }
122
+ /**
123
+ * One of the reading pane's own actions - Reply, Reply All, Forward, Archive, Move to Other - as an icon
124
+ * button. The action's name is its `aria-label` *and* its tooltip, so it has the same accessible name a
125
+ * labelled button had, and is never drawn beside the icon at any width: the row is icon-only everywhere,
126
+ * the way Outlook's own reading-pane command bar is, so it reads the same in the ~396px pane beside the
127
+ * message list and in a maximised window rather than changing shape at a breakpoint.
128
+ *
129
+ * A plain `<button>` rather than react-shared's `Button`, whose padding and minimum width are sized for a
130
+ * text label. Ordinary DOM order, so the keyboard reaches these in the order they are read.
131
+ */
132
+ function IconAction({ icon, label, onClick, disabled, busy, }) {
133
+ return (_jsx("button", { type: "button", "aria-label": label, title: label, "aria-busy": busy || undefined, disabled: disabled, onClick: onClick, 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", children: icon }));
134
+ }
118
135
  function SecurityIndicator({ security }) {
119
136
  const { label, className } = SECURITY_INDICATOR[security.state];
120
137
  if (security.state !== "verified_at_first_open") {
@@ -141,52 +158,10 @@ const ENCRYPTED_LOAD_ERROR = "Couldn't load this message's encrypted content.";
141
158
  * backstop behind the sanitizer below: nothing in the document may load any remote resource at all, only
142
159
  * inline styles and `data:`/`cid:` images. */
143
160
  const BODY_CSP_META = "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; img-src data: cid:; style-src 'unsafe-inline'\">";
144
- const EMBEDDED_URI = /^\s*(?:data|cid):/i;
145
- /** Attributes whose value makes the browser fetch a resource (as opposed to a user-clicked link). */
146
- const RESOURCE_URI_ATTRIBUTES = new Set(["src", "srcset", "background", "poster", "lowsrc", "dynsrc", "xlink:href", "action", "formaction"]);
147
- /** Decodes CSS escapes first (so an escaped `u\72l(` can't hide from the checks below), then drops every
148
- * `@import` and neutralizes every `url()`/`image-set()` reference that isn't a `data:`/`cid:` URI. */
149
- export function stripRemoteCssUrls(css) {
150
- return css
151
- .replace(/\\([0-9a-f]{1,6})\s?/gi, (_match, hex) => String.fromCodePoint(Math.min(parseInt(hex, 16), 0x10ffff)))
152
- .replace(/\\(.)/g, "$1")
153
- .replace(/@import[^;]*;?/gi, "")
154
- .replace(/(?:-webkit-)?image-set\((?:[^()]|\([^()]*\))*\)/gi, (match) => [...match.matchAll(/(["'])(.*?)\1/g)].every(([, , uri]) => EMBEDDED_URI.test(uri)) ? match : "none")
155
- .replace(/url\(\s*(["']?)(.*?)\1\s*\)/gi, (match, _quote, uri) => (EMBEDDED_URI.test(uri) ? match : "none"));
156
- }
157
- let bodyPurifier;
158
- /** A dedicated DOMPurify instance (hooks registered here never leak into any other DOMPurify caller)
159
- * that additionally strips every remote resource reference - see `stripRemoteCssUrls()`. */
160
- function getBodyPurifier() {
161
- if (!bodyPurifier) {
162
- bodyPurifier = DOMPurify(window);
163
- bodyPurifier.addHook("uponSanitizeElement", (node, data) => {
164
- if (data.tagName === "style") {
165
- // An element's `textContent` is always a string (only documents/doctypes yield null).
166
- node.textContent = stripRemoteCssUrls(node.textContent);
167
- }
168
- });
169
- bodyPurifier.addHook("uponSanitizeAttribute", (node, data) => {
170
- const name = data.attrName.toLowerCase();
171
- if (name === "style") {
172
- data.attrValue = stripRemoteCssUrls(data.attrValue);
173
- return;
174
- }
175
- const isNavigationLink = name === "href" && ["a", "area"].includes(node.nodeName.toLowerCase());
176
- if (RESOURCE_URI_ATTRIBUTES.has(name) || (name === "href" && !isNavigationLink)) {
177
- const candidates = name === "srcset" ? data.attrValue.split(/,\s+/) : [data.attrValue];
178
- if (!candidates.every((candidate) => EMBEDDED_URI.test(candidate))) {
179
- data.keepAttr = false;
180
- }
181
- }
182
- });
183
- }
184
- return bodyPurifier;
185
- }
186
- /** Sanitizes a client-rendered body for `srcDoc`, with the CSP meta as its very first element. */
161
+ /** Sanitizes a client-rendered body for `srcDoc` (DOMPurify plus every remote resource reference stripped - see
162
+ * react-shared's `messageBodySanitizer.ts`), with the CSP meta as its very first element. */
187
163
  export function buildSecureSrcDoc(html) {
188
- const sanitized = getBodyPurifier().sanitize(html, { FORBID_TAGS: ["link", "meta", "base"] });
189
- return BODY_CSP_META + sanitized;
164
+ return BODY_CSP_META + sanitizeMessageBodyHtml(html);
190
165
  }
191
166
  function formatBytes(bytes) {
192
167
  if (bytes >= 1000000)
@@ -198,9 +173,9 @@ function formatBytes(bytes) {
198
173
  /**
199
174
  * A message's reading pane — header (subject/from/to/attachments) plus a sandboxed iframe for the body.
200
175
  * Shared by the desktop inline pane (`apps/www/index.tsx`, always visible alongside the message list),
201
- * the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
202
- * a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
203
- * site for how `message`/`attachments`/`isSentItems` are sourced.
176
+ * and the mobile detail route (`apps/www/messages/[uid].tsx`, a full page on its own reached by tapping
177
+ * a message row) — see each call site for how `message`/`attachments`/`isSentItems` are sourced. The
178
+ * desktop pane shows whichever message the list opened, including one opened from a conversation row.
204
179
  */
205
180
  export default function MessageDetailPane(props) {
206
181
  if (!props.message) {
@@ -218,7 +193,7 @@ export default function MessageDetailPane(props) {
218
193
  function needsRawSecurityEvaluation(message) {
219
194
  return !!message.encrypted || message.hasAttachments;
220
195
  }
221
- function MessageDetailContent({ message: messageProp, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, isInbox, onClassified, onReceiptHandled, onArchived, labels, onLabelsChanged, }) {
196
+ function MessageDetailContent({ message: messageProp, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, folders, onMoved, onFolderCreated, onReceiptHandled, onArchived, labels, onLabelsChanged, onLabelCreated, inThread, }) {
222
197
  // A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
223
198
  // `reloadMessage()`. A prop copy newer by `version` wins; an equal-version reload is kept, since claiming a send
224
199
  // lease need not bump `version`.
@@ -232,16 +207,13 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
232
207
  const [canceling, setCanceling] = useState(false);
233
208
  const [archiving, setArchiving] = useState(false);
234
209
  const [archiveError, setArchiveError] = useState(null);
235
- const [labelsOpen, setLabelsOpen] = useState(false);
236
- const [togglingLabelUid, setTogglingLabelUid] = useState(null);
210
+ const [savingLabels, setSavingLabels] = useState(false);
237
211
  const [labelsError, setLabelsError] = useState(null);
238
212
  const [error, setError] = useState(null);
239
213
  // Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
240
214
  // pane rather than inside a confirmation modal — the two flows never need to share one message.
241
215
  const [cancelError, setCancelError] = useState(null);
242
- const [classifying, setClassifying] = useState(false);
243
- const [classifyError, setClassifyError] = useState(null);
244
- const [alwaysForSender, setAlwaysForSender] = useState(false);
216
+ const [movePrompt, setMovePrompt] = useState(false);
245
217
  // Names which pending receipt (`"delivery"`/`"read"`) is currently being approved/declined, if any —
246
218
  // `deliveryReceiptPending`/`readReceiptPending` can both be true independently, so a single boolean
247
219
  // wouldn't distinguish which row's buttons should show a loading state.
@@ -458,38 +430,60 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
458
430
  clearPinnedSignerCache();
459
431
  setUnlockRefresh((n) => n + 1);
460
432
  }
461
- // Only ever invoked from the Reply/Reply All/Forward buttons below, which themselves only render
462
- // once `message` is loaded (the early return above covers the only other state) — the non-null
463
- // assertions reflect that real invariant, matching `handleRecall`'s identical pattern just below.
464
- function handleReply() {
465
- openCompose({
466
- mailboxUid: message.mailboxUid,
467
- to: message.from.address,
468
- subject: replySubject(message.subject),
469
- quotedHtml: buildReplyQuote(message),
470
- signatureContext: "reply_forward",
471
- suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
472
- });
473
- }
474
- function handleReplyAll() {
475
- const cc = message.recipients.filter((r) => r.type !== "bcc").map((r) => r.address);
476
- openCompose({
477
- mailboxUid: message.mailboxUid,
478
- to: message.from.address,
479
- cc: cc.join(", "),
480
- subject: replySubject(message.subject),
481
- quotedHtml: buildReplyQuote(message),
482
- signatureContext: "reply_forward",
483
- suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
484
- });
433
+ // Reply, Reply All and Forward first load the full body to quote (see `loadQuotedBody()`), so the buttons are
434
+ // disabled meanwhile - a second click would open a second window. An encrypted original keeps its reply
435
+ // encrypted, since the quote may carry its decrypted content.
436
+ const [preparingCompose, setPreparingCompose] = useState(false);
437
+ /** The replying mailbox's own addresses (primary and aliases), which a reply never goes to - from the mail shell's
438
+ * mailbox list, else fetched; none when neither is available. */
439
+ async function ownAddresses() {
440
+ const mailbox = readerMailbox ?? (await getMailbox(message.mailboxUid).catch(() => undefined));
441
+ return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
485
442
  }
486
- function handleForward() {
487
- openCompose({
488
- mailboxUid: message.mailboxUid,
489
- subject: forwardSubject(message.subject),
490
- quotedHtml: buildForwardQuote(message),
491
- signatureContext: "reply_forward",
492
- });
443
+ async function handleReplyOrForward(kind) {
444
+ setPreparingCompose(true);
445
+ try {
446
+ const [original, own] = await Promise.all([
447
+ loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
448
+ kind === "forward" ? [] : ownAddresses(),
449
+ ]);
450
+ const encrypt = !!message.encrypted;
451
+ // What makes the new message part of this one's thread rather than a conversation of its own:
452
+ // the server composes the MIME from the recipients, subject and HTML alone, so nothing else
453
+ // recovers what is being replied to. A forward carries it for the same reason - it continues
454
+ // the thread it came from, which is where its recipient will file the reply to it.
455
+ const threading = buildReplyThreading(message);
456
+ if (kind === "forward") {
457
+ openCompose({
458
+ mailboxUid: message.mailboxUid,
459
+ subject: forwardSubject(message.subject),
460
+ quotedHtml: buildForwardQuote(message, original.body),
461
+ signatureContext: "reply_forward",
462
+ encrypt,
463
+ threading,
464
+ });
465
+ return;
466
+ }
467
+ // The message's own recipients are only the envelope recipient of a delivered message, so Reply All
468
+ // answers whoever its headers actually name, with anything the record holds that they don't.
469
+ const known = new Set((original.recipients ?? []).map((r) => r.address.trim().toLowerCase()));
470
+ const recipients = buildReplyRecipients({ ...message, recipients: [...(original.recipients ?? []), ...message.recipients.filter((r) => !known.has(r.address.trim().toLowerCase()))] }, own, kind === "replyAll");
471
+ const format = (list) => list.map((r) => formatRecipient(r)).join(", ");
472
+ openCompose({
473
+ mailboxUid: message.mailboxUid,
474
+ to: format(recipients.to),
475
+ cc: recipients.cc.length > 0 ? format(recipients.cc) : undefined,
476
+ subject: replySubject(message.subject),
477
+ quotedHtml: buildReplyQuote(message, original.body),
478
+ signatureContext: "reply_forward",
479
+ suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
480
+ encrypt,
481
+ threading,
482
+ });
483
+ }
484
+ finally {
485
+ setPreparingCompose(false);
486
+ }
493
487
  }
494
488
  async function handleRecall() {
495
489
  // Only ever invoked from the confirmation modal below, which itself only renders once `message`
@@ -554,26 +548,19 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
554
548
  setArchiving(false);
555
549
  }
556
550
  }
557
- // Only ever invoked from a checkbox in the Labels popover below, which itself only renders once
558
- // `message` is loaded — auto-saves on every toggle (no separate "Save" step), computing the full
559
- // new `labelUids` set from the message's current one since `setMessageLabels()` replaces the whole
560
- // list rather than patching a single entry.
551
+ // Only ever invoked from the Labels menu below, which itself only renders once `message` is loaded.
552
+ // Several labels are ticked in the menu and saved together here, in one `setMessageLabels()` call:
553
+ // that call replaces the whole `labelUids` list rather than patching one entry, so a menu that saved
554
+ // per tick would send a request (and burn an optimistic-lock `version`) for every single tick.
561
555
  //
562
- // Toggles are serialized (every checkbox is disabled while one is in flight - React flushes a
563
- // discrete input event's state update synchronously, so a second change can't slip in first), and
564
- // each one reads the latest known copy (`latestLabelsMessageRef`, never a stale render closure) - so
565
- // two quick toggles can never compute their full list, or send their optimistic-lock `version`, from
566
- // the same stale snapshot.
567
- async function handleToggleLabel(labelUid) {
568
- setTogglingLabelUid(labelUid);
556
+ // The list is computed from the newest copy known (`latestLabelsMessageRef`, never a stale render
557
+ // closure), so a save that follows another still carries the `version` the server last handed back.
558
+ async function handleApplyLabels(labelUids) {
559
+ setSavingLabels(true);
569
560
  setLabelsError(null);
570
561
  try {
571
- // Read through the ref (not this render's closure) so a toggle fired from a not-yet-re-rendered
572
- // handler still sees the previous toggle's server response.
573
562
  const base = message.version >= latestLabelsMessageRef.current.version ? message : latestLabelsMessageRef.current;
574
- const current = base.labelUids ?? [];
575
- const next = current.includes(labelUid) ? current.filter((uid) => uid !== labelUid) : [...current, labelUid];
576
- const updated = await setMessageLabels(base, next);
563
+ const updated = await setMessageLabels(base, labelUids);
577
564
  latestLabelsMessageRef.current = updated;
578
565
  setLabelsMessage(updated);
579
566
  onLabelsChanged?.(updated);
@@ -582,25 +569,21 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
582
569
  setLabelsError(err instanceof ApiRequestError ? err.message : "Could not update this message's labels.");
583
570
  }
584
571
  finally {
585
- setTogglingLabelUid(null);
572
+ setSavingLabels(false);
586
573
  }
587
574
  }
588
- // Only ever invoked from the classification button below, which itself only renders once `message`
589
- // is loaded and `isInbox` is true — the non-null assertion reflects the same real invariant as
590
- // `handleRecall`/`handleCancelScheduledSend` above.
591
- async function handleClassify(classifyAs) {
592
- setClassifying(true);
593
- setClassifyError(null);
594
- try {
595
- const updated = await classifyMessage(message.uid, classifyAs, alwaysForSender);
596
- onClassified?.(updated);
597
- }
598
- catch (err) {
599
- setClassifyError(err instanceof ApiRequestError ? err.message : "Could not reclassify this message.");
600
- }
601
- finally {
602
- setClassifying(false);
603
- }
575
+ /**
576
+ * Moves this message into the folder picked in the Move to prompt. Deliberately *not* caught here:
577
+ * `MoveToFolderDialog` shows a failure beside the destination that would retry it, and closes only
578
+ * once the move has actually landed.
579
+ *
580
+ * The move is awaited on its own line rather than written as `onMoved?.(await moveMessage(...))` -
581
+ * an optional call whose callee is absent never evaluates its arguments at all, so a caller that
582
+ * passes no `onMoved` would have had nothing moved while the prompt closed as if it had.
583
+ */
584
+ async function handleMove(folderUid) {
585
+ const updated = await moveMessage(message, folderUid);
586
+ onMoved?.(updated);
604
587
  }
605
588
  // Only ever invoked from the pending-receipt banner below, which itself only renders once `message`
606
589
  // is loaded — same real invariant as every other handler above.
@@ -657,7 +640,24 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
657
640
  const recordedConflict = keyChanged && sameFingerprint(senderKeyState?.conflict?.observedKey.fingerprint, keyChanged.signerFingerprint)
658
641
  ? senderKeyState.conflict
659
642
  : undefined;
660
- return (_jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("div", { className: "border-b border-border p-4", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block mb-2", children: "\u2190 Back to messages" })), _jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight truncate", children: (protectedSubject ?? message.subject) || "(no subject)" }), security && _jsx(SecurityIndicator, { security: security })] }), sendInProgress && (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Sending\u2026" })), isSentItems &&
643
+ const shownSubject = (protectedSubject ?? message.subject) || "(no subject)";
644
+ /**
645
+ * How tall the message body is - the one thing on this pane worth every pixel it can have.
646
+ *
647
+ * The same rule in both places: the pane is a full-height flex column and the body is its one growing
648
+ * child, so it fills whatever the header leaves (`min-h-0` on both, or a tall body would stretch the
649
+ * column past the pane instead of scrolling inside it). Inside a thread that height comes from the
650
+ * message's own row, which `ConversationThreadPane` gives `min-h-full` of its scrolling list - so the
651
+ * height is still the window's, resolved down the flex chain rather than written here as a `vh`
652
+ * number, and it follows a resize with no breakpoint to cross.
653
+ *
654
+ * Why not size it to its content: that would need the document *inside* the frame to measure and
655
+ * report itself, and these frames are `sandbox=""` - scripts off - because they render mail from
656
+ * strangers. So a message longer than the pane keeps its own scrollbar, and everything shorter shows
657
+ * whole with the frame reaching the bottom of the pane either way.
658
+ */
659
+ const bodyClassName = "flex-1 min-h-0 w-full";
660
+ return (_jsxs("div", { className: `flex-1 min-w-0 min-h-0 flex flex-col${inThread ? "" : " h-full"}`, children: [_jsxs("div", { className: "border-b border-border p-4", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block mb-2", children: "\u2190 Back to messages" })), _jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [inThread ? (_jsx("h3", { className: "text-sm font-semibold truncate", children: shownSubject })) : (_jsx("h1", { className: "text-lg font-bold tracking-tight truncate", children: shownSubject })), security && _jsx(SecurityIndicator, { security: security })] }), sendInProgress && (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Sending\u2026" })), isSentItems &&
661
661
  (message.recallRequestedAt ? (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Recall requested" })) : (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setConfirming(true), children: "Recall this message" }))), inOutbox && !sendInProgress && message.scheduledSendTime && (_jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsxs("span", { className: "text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt", children: ["Scheduled for ", new Date(message.scheduledSendTime).toLocaleString()] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Cancel" })] })), inOutbox && !sendInProgress && !message.scheduledSendTime && draftsFolderUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Move to Drafts" }))] }), inOutbox && message.scheduledSendError && (_jsx("div", { className: "mt-2", children: _jsxs(Alert, { children: ["This message wasn\u2019t sent: ", message.scheduledSendError] }) })), cancelError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: cancelError }) })), keyChangeNotice && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: keyChangeNotice })), security && UNVERIFIED_SIGNER_STATES.has(security.state) && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: ["Signed, but the signer isn\u2019t a trusted contact key, so the sender isn\u2019t verified.", security.signerEmails && security.signerEmails.length > 0 && _jsxs(_Fragment, { children: [" Certificate for ", security.signerEmails.join(", "), "."] }), security.signerFingerprint && (_jsxs(_Fragment, { children: [" ", "Fingerprint ", _jsx("span", { className: "font-mono text-xs break-all", children: security.signerFingerprint }), "."] }))] })), pendingConflict && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["This sender has a signing key change waiting for your review, so this signer can\u2019t be trusted from here.", " ", _jsx("a", { href: `/contacts/${encodeURIComponent(senderKeyState.contactUid)}`, className: "font-medium text-primary-dark hover:underline", children: "Review it in Contacts" })] })), trustableCertificate && (_jsx("div", { className: "mt-1.5", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => {
662
662
  setTrustError(null);
663
663
  setTrustConfirmOpen(true);
@@ -668,15 +668,12 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
668
668
  source: recordedConflict?.source,
669
669
  }, certificate: keyChanged.signerCertificate, canReject: recordedConflict !== undefined, canResolve: canUpdateMailbox, onResolved: (action) => refreshAfterKeyChange(action === "reject" ? KEPT_CURRENT_SIGNING_KEY_MESSAGE : null), onPinnedKeyChanged: () => refreshAfterKeyChange(KEY_CHANGE_STALE_MESSAGE) })] })), security?.state === "signature_failed" && !keyChanged && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: security.signatureFailureReason
670
670
  ? SIGNATURE_FAILURE_MESSAGE[security.signatureFailureReason]
671
- : GENERIC_SIGNATURE_FAILURE_MESSAGE })), verifiedOrSealed && !security.protectedHeaders && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The signature covers this message\u2019s content and attachments only. Its Subject, To and Cc weren\u2019t signed, so they could have been changed after it was sent." })), security?.notAddressedToReader && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The recipients this message was signed or encrypted for don\u2019t include this mailbox - it may have been forwarded or re-sent to you unchanged, or you were Bcc\u2019d." })), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["From ", senderLabel, " \u00B7 ", new Date(message.receivedDate).toLocaleString()] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsxs("p", { className: "text-sm text-text-muted", children: ["To ", message.recipients.map((r) => r.displayName || r.address).join(", ")] }), _jsxs("div", { className: "flex gap-2 mt-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleReply, children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleReplyAll, children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleForward, children: "Forward" }), !inOutbox && message.folderUid !== draftsFolderUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: archiving, disabled: archiving, onClick: handleArchive, children: "Archive" })), labels && labels.length > 0 && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setLabelsOpen(true), children: "Labels" }))] }), archiveError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: archiveError }) })), labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 mt-2", children: currentLabelsMessage
671
+ : GENERIC_SIGNATURE_FAILURE_MESSAGE })), verifiedOrSealed && !security.protectedHeaders && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The signature covers this message\u2019s content and attachments only. Its Subject, To and Cc weren\u2019t signed, so they could have been changed after it was sent." })), security?.notAddressedToReader && (_jsx("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: "The recipients this message was signed or encrypted for don\u2019t include this mailbox - it may have been forwarded or re-sent to you unchanged, or you were Bcc\u2019d." })), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["From ", senderLabel, " \u00B7 ", new Date(message.receivedDate).toLocaleString()] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsxs("p", { className: "text-sm text-text-muted", children: ["To ", message.recipients.map((r) => r.displayName || r.address).join(", ")] }), _jsxs("div", { className: "flex flex-wrap gap-1 mt-3", children: [_jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward") }), !inOutbox && message.folderUid !== draftsFolderUid && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", busy: archiving, disabled: archiving, onClick: handleArchive })), folders && folders.length > 0 && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", onClick: () => setMovePrompt(true) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }))] }), archiveError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: archiveError }) })), labelsError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: labelsError }) })), labels && (currentLabelsMessage.labelUids?.length ?? 0) > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 mt-2", children: currentLabelsMessage
672
672
  .labelUids.map((uid) => labels.find((l) => l.uid === uid))
673
673
  .filter((l) => !!l)
674
- .map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), isInbox && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 mt-2", children: [_jsx(Button, { type: "button", variant: "text", loading: classifying, disabled: classifying, onClick: () => handleClassify(message.inferenceClassification === "other" ? "focused" : "other"), children: message.inferenceClassification === "other" ? "Move to Focused" : "Move to Other" }), _jsxs("label", { className: "flex items-center gap-1.5 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: alwaysForSender, onChange: (e) => setAlwaysForSender(e.target.checked) }), "Always for this sender"] })] })), classifyError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: classifyError }) })), ["delivery", "read"]
674
+ .map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), ["delivery", "read"]
675
675
  .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
676
676
  .map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 mt-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: receiptError }) })), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "mt-3 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
677
677
  ? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 mt-3", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: _jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` }) }, `${index}:${attachment.filename ?? ""}`))) }))
678
- : attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 mt-3", children: attachments.map((attachment) => (_jsx("li", { children: _jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] }) }, attachment.uid))) }))] }), security?.decryptError && (_jsxs("div", { className: "px-4 pt-2", children: [_jsx(Alert, { children: security.decryptError }), !getUnlockedKeys(message.mailboxUid) && (_jsxs("button", { type: "button", onClick: handleUnlockToView, className: "mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to view this message"] }))] })), security?.text !== undefined ? (_jsx("pre", { "aria-label": message.subject || "Message content", className: "flex-1 w-full overflow-auto p-4 m-0 text-sm font-sans whitespace-pre-wrap break-words", children: security.text })) : security?.html !== undefined ? (_jsx("iframe", { title: message.subject || "Message content", srcDoc: buildSecureSrcDoc(security.html), sandbox: "", className: "flex-1 w-full border-0" }, message.uid)) : (_jsx("iframe", { title: message.subject || "Message content", src: `/api/mail/messages/${encodeURIComponent(message.uid)}/content`, sandbox: "", className: "flex-1 w-full border-0" }, message.uid)), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) }), _jsxs(Modal, { open: labelsOpen, onClose: () => setLabelsOpen(false), title: "Labels", children: [labelsError && _jsx(Alert, { children: labelsError }), _jsx("ul", { className: "flex flex-col gap-1", children: (labels ?? []).map((l) => {
679
- const checked = currentLabelsMessage.labelUids?.includes(l.uid) ?? false;
680
- return (_jsx("li", { children: _jsxs("label", { className: "flex items-center gap-2 py-1.5 px-1 text-sm rounded-sm hover:bg-surface-alt", children: [_jsx("input", { type: "checkbox", checked: checked, disabled: togglingLabelUid !== null, onChange: () => handleToggleLabel(l.uid) }), _jsx("span", { className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }) }, l.uid));
681
- }) })] })] }));
678
+ : attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 mt-3", children: attachments.map((attachment) => (_jsx("li", { children: _jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] }) }, attachment.uid))) }))] }), security?.decryptError && (_jsxs("div", { className: "px-4 pt-2", children: [_jsx(Alert, { children: security.decryptError }), !getUnlockedKeys(message.mailboxUid) && (_jsxs("button", { type: "button", onClick: handleUnlockToView, className: "mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to view this message"] }))] })), security?.text !== undefined ? (_jsx("pre", { "aria-label": message.subject || "Message content", className: `${bodyClassName} overflow-auto p-4 m-0 text-sm font-sans whitespace-pre-wrap break-words`, children: security.text })) : security?.html !== undefined ? (_jsx("iframe", { title: message.subject || "Message content", srcDoc: buildSecureSrcDoc(security.html), sandbox: "", className: `${bodyClassName} border-0` }, message.uid)) : (_jsx("iframe", { title: message.subject || "Message content", src: `/api/mail/messages/${encodeURIComponent(message.uid)}/content`, sandbox: "", className: `${bodyClassName} border-0` }, message.uid)), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
682
679
  }
@@ -0,0 +1,57 @@
1
+ import React from "react";
2
+ import { Folder } from "@rapidmx/react-shared/mail/mailApi.js";
3
+ /**
4
+ * The folder types a message can be moved *into* - Outbox is a transient send queue the server owns, and
5
+ * the non-mail folders (`calendar`/`contacts`/`tasks`/`notes`) aren't message folders at all, so neither is
6
+ * offered. Shared by the reading pane's Move to and select mode's bulk Move to, which is the whole point of
7
+ * this component: one destination list, one set of rules.
8
+ */
9
+ export declare const MOVE_TARGET_TYPES: Set<string>;
10
+ /** Above this many destinations the dialog offers a filter box - below it, one is just another thing to
11
+ * skip past on the way to a list that already fits on screen. */
12
+ export declare const FOLDER_FILTER_THRESHOLD = 8;
13
+ /** What `@rapidmx/restapi` accepts as a folder name, so a name it would refuse is refused here, next to the
14
+ * field, rather than as a 400 after a round trip. */
15
+ export declare const MAX_FOLDER_NAME_LENGTH = 255;
16
+ /**
17
+ * Why a name can't be used, or `null` when it can. Folders are a real tree (`Folder.parentFolderUid`), not
18
+ * paths, so a separator in a name would be a character in the name rather than a nesting instruction -
19
+ * which is exactly why it is refused: it would read as a hierarchy this app never created.
20
+ */
21
+ export declare function folderNameError(name: string, existing: Folder[]): string | null;
22
+ export interface MoveToFolderDialogProps {
23
+ open: boolean;
24
+ onClose: () => void;
25
+ /** The mailbox whose folders are offered, and the one a new folder is created in. */
26
+ mailboxUid: string;
27
+ /** Every folder of that mailbox - filtered to the movable types here, so callers hand over the list
28
+ * they already hold rather than pre-filtering it each time. */
29
+ folders: Folder[];
30
+ /** Where the message(s) are now: offered, but disabled, so the list doesn't change shape per folder. */
31
+ currentFolderUid?: string;
32
+ /** What is being moved, for the dialog's own title ("Move 3 messages to"). Defaults to one message. */
33
+ count?: number;
34
+ /**
35
+ * Performs the move, resolving once it has settled. A rejection is shown in this dialog, beside the
36
+ * destination that would retry it; the dialog closes on success. The bulk caller resolves either way
37
+ * and reports its own outcome in the selection bar, which is where a partly-applied bulk update has to
38
+ * be explained (see `runBulkAction()`).
39
+ */
40
+ onMove: (folderUid: string) => Promise<void>;
41
+ /** A folder this dialog created, so the caller's own folder list - and the sidebar - pick it up without
42
+ * a page load. Called before the move, so a failed move still leaves the folder that was created. */
43
+ onFolderCreated?: (folder: Folder) => void;
44
+ }
45
+ /**
46
+ * Outlook's "Move to" prompt: pick a destination folder for the open message or the selection, or create a
47
+ * folder and move into it in one step.
48
+ *
49
+ * A `Modal` rather than a `MenuButton` because creating a folder needs a text field, and a `role="menu"`
50
+ * has nowhere to put one (the same reason `NewLabelDialog` exists) - and because a destination list can be
51
+ * as long as the mailbox has folders, which wants a filter box rather than a scrolling popup.
52
+ *
53
+ * A new folder is created at the **top level of the mailbox**, typed `user`, never as a child of the folder
54
+ * being moved out of: this app's sidebar lists folders flat (`MailShell`'s own `FOLDER_ORDER`), so a
55
+ * subfolder would be created into a hierarchy nothing here renders.
56
+ */
57
+ export default function MoveToFolderDialog({ open, onClose, mailboxUid, folders, currentFolderUid, count, onMove, onFolderCreated, }: MoveToFolderDialogProps): React.JSX.Element;