@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.
- package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -152
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
- package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
- package/apps/shared/components/layout/AppShell.tsx +288 -288
- package/apps/shared/components/mail/ConversationList.tsx +225 -33
- package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
- package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
- package/apps/shared/components/mail/MenuButton.tsx +384 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
- package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
- package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
- package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
- package/apps/shared/components/mail/compose/recipients.ts +104 -0
- package/apps/shared/components/mail/labelMenu.tsx +278 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +445 -420
- package/apps/shared/components/mail/listPreferences.ts +228 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +0 -1
- package/apps/shared/styles/app.css +28 -5
- package/apps/www/index.tsx +1854 -1314
- package/apps/www/messages/[uid].tsx +5 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +7 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +4 -1
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +9 -2
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +20 -4
- package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +8 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +4 -1
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
- package/dist/apps/shared/components/mail/ConversationList.d.ts +42 -6
- package/dist/apps/shared/components/mail/ConversationList.js +84 -10
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +52 -13
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
- package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
- package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
- package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
- package/dist/apps/shared/components/mail/MenuButton.js +211 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +27 -19
- package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
- package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
- package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +16 -1
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +10 -1
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
- package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
- package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
- package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
- package/dist/apps/shared/components/mail/labelMenu.js +134 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +8 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +15 -3
- package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
- package/dist/apps/shared/components/mail/listPreferences.js +186 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +0 -1
- package/dist/apps/shared/styles/app.css +28 -5
- package/dist/apps/www/index.js +506 -112
- package/dist/apps/www/messages/[uid].js +2 -3
- package/package.json +2 -2
- package/apps/www/settings/focused-inbox/index.tsx +0 -150
- package/dist/apps/www/settings/focused-inbox/index.d.ts +0 -4
- 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
|
-
/**
|
|
28
|
-
*
|
|
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
|
-
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*
|
|
65
|
-
|
|
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
|
-
* `
|
|
69
|
-
*
|
|
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)
|
|
91
|
-
*
|
|
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,
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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)
|
|
203
|
-
*
|
|
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,
|
|
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 [
|
|
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 [
|
|
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
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
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
|
-
//
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
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
|
|
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
|
-
|
|
572
|
+
setSavingLabels(false);
|
|
586
573
|
}
|
|
587
574
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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-
|
|
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))) })),
|
|
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:
|
|
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;
|