@rapidmx/web-client 0.2.0 → 0.3.1
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/admin/data-requests/index.tsx +485 -0
- package/apps/admin/escrow-scopes/[uid].tsx +237 -0
- package/apps/admin/escrow-scopes/index.tsx +124 -0
- package/apps/admin/escrow-scopes/new/index.tsx +128 -0
- package/apps/admin/mailboxes/[uid].tsx +66 -8
- package/apps/admin/retention-policy/index.tsx +132 -0
- package/apps/escrow/_layout.tsx +41 -0
- package/apps/escrow/audit-log/index.tsx +168 -0
- package/apps/escrow/index.tsx +139 -0
- package/apps/escrow/matters/[uid].tsx +521 -0
- package/apps/escrow/matters/new/index.tsx +173 -0
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
- package/apps/shared/components/forms/StringListField.tsx +104 -0
- package/apps/shared/components/layout/AppShell.tsx +4 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
- package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
- package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
- package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
- package/apps/www/index.tsx +122 -11
- package/apps/www/messages/[uid].tsx +15 -1
- package/apps/www/settings/encryption/index.tsx +664 -0
- package/apps/www/settings/labels/index.tsx +201 -0
- package/apps/www/settings/privacy/index.tsx +436 -0
- package/dist/apps/admin/data-requests/index.js +197 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
- package/dist/apps/admin/escrow-scopes/index.js +31 -0
- package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
- package/dist/apps/admin/mailboxes/[uid].js +26 -2
- package/dist/apps/admin/retention-policy/index.js +65 -0
- package/dist/apps/escrow/_layout.js +10 -0
- package/dist/apps/escrow/audit-log/index.js +66 -0
- package/dist/apps/escrow/index.js +31 -0
- package/dist/apps/escrow/matters/[uid].js +234 -0
- package/dist/apps/escrow/matters/new/index.js +75 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
- package/dist/apps/shared/components/forms/StringListField.js +39 -0
- package/dist/apps/shared/components/layout/AppShell.js +4 -0
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
- package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
- package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
- package/dist/apps/www/index.js +110 -10
- package/dist/apps/www/messages/[uid].js +12 -2
- package/dist/apps/www/settings/encryption/index.js +330 -0
- package/dist/apps/www/settings/labels/index.js +95 -0
- package/dist/apps/www/settings/privacy/index.js +167 -0
- package/package.json +9 -3
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import "../../../styles/app.css";
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { HiOutlineClipboardDocumentList, HiOutlineFolderOpen } from "react-icons/hi2";
|
|
9
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
10
|
+
import { listMatters } from "@rapidmx/react-shared/admin/mattersApi.js";
|
|
11
|
+
import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
|
|
12
|
+
import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
|
|
13
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
14
|
+
import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
|
|
15
|
+
import { BrandingFooter, BrandingHeader } from "../../layout/BrandingChrome.js";
|
|
16
|
+
import UserMenu from "../../layout/UserMenu.js";
|
|
17
|
+
const NAV_ITEMS = [
|
|
18
|
+
{ id: "matters", href: "/escrow", label: "Matters", icon: HiOutlineFolderOpen },
|
|
19
|
+
{ id: "auditLog", href: "/escrow/audit-log", label: "Audit Log", icon: HiOutlineClipboardDocumentList },
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Gates every `apps/escrow` page behind "is this an authenticated caller at all" — deliberately **not**
|
|
23
|
+
* the same "does a canary endpoint 403 me" pattern `AdminShell` uses for the trusted `admin` role.
|
|
24
|
+
* `specs/end-to-end_encryption.md`'s "Separation of duties" makes holder-ness a per-`EscrowScope` grant
|
|
25
|
+
* (`EscrowScope.holderUserUids`), checked directly against `holderUserUids` in every restapi route that
|
|
26
|
+
* needs it (`requireEscrowHolder()`) — never a role, and never global. There is no canary endpoint a
|
|
27
|
+
* non-holder gets a clean 403 from: `GET /escrow/matters` (`BaseMatterRoute.find()`) returns `200 []` for
|
|
28
|
+
* *any* authenticated caller who holds no scope at all, the same as it would for a holder of zero matters
|
|
29
|
+
* — so unlike `AdminShell`, this shell cannot distinguish "holds nothing" from "holds no scope at all" up
|
|
30
|
+
* front, and does not try to. It only confirms the caller is signed in and the API is reachable; every
|
|
31
|
+
* actual holder-gated action (create a Matter, approve/deny a request, read material) is enforced
|
|
32
|
+
* server-side and surfaces its own 403 `ApiRequestError` on the specific page that attempted it, same as
|
|
33
|
+
* any other gated action elsewhere in this codebase. A non-holder who reaches `/escrow` simply sees an
|
|
34
|
+
* empty Matters list and gets a real error if they try to act on something they don't hold — this is a
|
|
35
|
+
* deliberate, documented scope trim rather than an oversight (see this session's own report on it).
|
|
36
|
+
*/
|
|
37
|
+
export default function EscrowShell({ active, userUid, authServerUrl, children }) {
|
|
38
|
+
const [status, setStatus] = useState("checking");
|
|
39
|
+
const [error, setError] = useState(null);
|
|
40
|
+
const { branding, iconSrc } = useBranding();
|
|
41
|
+
useRedirectIfUnauthenticated(userUid, authServerUrl);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!userUid) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// limit=1 - this call exists only to confirm the API is reachable for this signed-in caller, not
|
|
47
|
+
// to read any real data (every page below fetches its own data itself).
|
|
48
|
+
listMatters({ limit: 1 })
|
|
49
|
+
.then(() => setStatus("authorized"))
|
|
50
|
+
.catch((err) => {
|
|
51
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not verify escrow console access.");
|
|
52
|
+
setStatus("error");
|
|
53
|
+
});
|
|
54
|
+
}, [userUid]);
|
|
55
|
+
function handleSignOut() {
|
|
56
|
+
window.location.href = authServerUrl ?? "/";
|
|
57
|
+
}
|
|
58
|
+
let content;
|
|
59
|
+
if (!userUid || status === "checking") {
|
|
60
|
+
content = _jsx("div", { className: "min-h-screen" });
|
|
61
|
+
}
|
|
62
|
+
else if (status === "error") {
|
|
63
|
+
content = (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8", children: _jsx("div", { className: "w-full max-w-md", children: _jsx(Alert, { children: error }) }) }));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const activeItem = NAV_ITEMS.find((item) => item.id === active);
|
|
67
|
+
content = (_jsx("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Escrow sections", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), NAV_ITEMS.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
|
|
68
|
+
"w-10 h-10 flex items-center justify-center rounded-sm",
|
|
69
|
+
id === active
|
|
70
|
+
? "bg-primary/10 text-primary-dark"
|
|
71
|
+
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
72
|
+
].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: NAV_ITEMS, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeItem?.label }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut })] }), _jsx("div", { className: "flex-1 pb-14 md:pb-0", children: _jsx("main", { className: "max-w-6xl mx-auto px-6 py-8", children: children }) })] })] }) }));
|
|
73
|
+
}
|
|
74
|
+
return (_jsxs(_Fragment, { children: [_jsx(BrandingHeader, { branding: branding }), content, _jsx(BrandingFooter, { branding: branding })] }));
|
|
75
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
8
|
+
/**
|
|
9
|
+
* A plain, controlled add/remove list-of-strings editor — the same visual add-row/per-item-remove shape
|
|
10
|
+
* `MemberListCard` already established for a `DistributionList`'s `memberAddresses`, but operating on a
|
|
11
|
+
* local `value`/`onChange` pair rather than persisting itself via its own API call. `MemberListCard`
|
|
12
|
+
* isn't reusable here as-is: `EscrowScope.holderUserUids` (at least one required) and
|
|
13
|
+
* `Matter.custodianMailboxUids` (also non-empty) are both required *at creation time*, before there's any
|
|
14
|
+
* uid yet to `PUT` against — this needs to compose into an ordinary create/edit form's own local state
|
|
15
|
+
* instead, same as every other field on those forms.
|
|
16
|
+
*/
|
|
17
|
+
export default function StringListField({ label, id, values, onChange, placeholder, emptyMessage = "None added yet.", disabled, }) {
|
|
18
|
+
const [draft, setDraft] = useState("");
|
|
19
|
+
/** Accepts either the "Add" button's own form submit or the input's Enter keydown — both just need
|
|
20
|
+
* `preventDefault()` (submitting would otherwise reload the page; Enter would otherwise do nothing
|
|
21
|
+
* harmful here, but is prevented for consistency). */
|
|
22
|
+
function handleAdd(e) {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
const trimmed = draft.trim();
|
|
25
|
+
if (!trimmed || values.includes(trimmed)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
onChange([...values, trimmed]);
|
|
29
|
+
setDraft("");
|
|
30
|
+
}
|
|
31
|
+
function handleRemove(value) {
|
|
32
|
+
onChange(values.filter((v) => v !== value));
|
|
33
|
+
}
|
|
34
|
+
return (_jsxs("div", { className: "mb-4", children: [_jsx("label", { htmlFor: id, className: "block text-sm font-semibold mb-1.5 text-text", children: label }), _jsxs("ul", { className: "flex flex-col gap-2 mb-2", children: [values.length === 0 && _jsx("li", { className: "text-sm text-text-muted", children: emptyMessage }), values.map((value) => (_jsxs("li", { className: "flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm", children: [_jsx("span", { className: "text-sm font-medium break-all", children: value }), _jsx("button", { type: "button", onClick: () => handleRemove(value), disabled: disabled, className: "text-sm text-danger hover:underline disabled:opacity-55 shrink-0", children: "Remove" })] }, value)))] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { id: id, type: "text", value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: (e) => {
|
|
35
|
+
if (e.key === "Enter") {
|
|
36
|
+
handleAdd(e);
|
|
37
|
+
}
|
|
38
|
+
}, placeholder: placeholder, disabled: disabled, className: "flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary" }), _jsx(Button, { type: "button", variant: "secondary", disabled: disabled, className: "!w-auto", onClick: handleAdd, children: "Add" })] })] }));
|
|
39
|
+
}
|
|
@@ -9,6 +9,7 @@ import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelop
|
|
|
9
9
|
import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
|
|
10
10
|
import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
11
11
|
import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
|
|
12
|
+
import { useIdleKeyTimeout } from "@rapidmx/react-shared/crypto/useIdleKeyTimeout.js";
|
|
12
13
|
import ComposeProvider from "../mail/compose/ComposeContext.js";
|
|
13
14
|
import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
|
|
14
15
|
import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
|
|
@@ -39,6 +40,9 @@ export default function AppShell({ active, userUid, authServerUrl, impersonating
|
|
|
39
40
|
const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
|
|
40
41
|
const { branding, iconSrc } = useBranding();
|
|
41
42
|
useRedirectIfUnauthenticated(userUid, authServerUrl);
|
|
43
|
+
// Mounted here, not scoped to Mail/Settings (the only shells that actually read unlocked keys),
|
|
44
|
+
// specifically so activity in *any* app resets the idle clock - see that hook's own doc comment.
|
|
45
|
+
useIdleKeyTimeout();
|
|
42
46
|
function handleSignOut() {
|
|
43
47
|
window.location.href = authServerUrl ?? "/";
|
|
44
48
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
9
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
10
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
11
|
+
import { enrollKey, getKeyVault } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
12
|
+
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, unlockWithPassword, } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
13
|
+
import { buildAad, generateMasterKey, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
|
|
14
|
+
import { buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
|
|
15
|
+
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "@rapidmx/react-shared/crypto/keys.js";
|
|
16
|
+
const MIN_PASSWORD_LENGTH = 8;
|
|
17
|
+
async function provisionEncryptionKey(mailboxUid, mailboxAddress, password) {
|
|
18
|
+
const mk = generateMasterKey();
|
|
19
|
+
const passwordWrap = await buildPasswordWrap(mailboxUid, mk, password);
|
|
20
|
+
const { wraps: recoveryWraps, codes: recoveryCodes } = await buildRecoveryWraps(mailboxUid, mk);
|
|
21
|
+
// Only the encryption key is provisioned here. The signing key's spec-required public-CA enrolment
|
|
22
|
+
// (RFC 8823 ACME automation) is a real but genuinely asynchronous flow (a live email round-trip with
|
|
23
|
+
// a public CA, likely minutes) - it's a deliberate opt-in action in Settings > Encryption
|
|
24
|
+
// ("Enable digital signatures"), not something to block first-sign-in mailbox setup on.
|
|
25
|
+
const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
|
|
26
|
+
const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
|
|
27
|
+
const wrappedKeySealed = await sealWithKey(mk, privateKeyRaw, buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE));
|
|
28
|
+
await enrollKey(mailboxUid, {
|
|
29
|
+
useType: "encrypt",
|
|
30
|
+
csr: csrPem,
|
|
31
|
+
wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
|
|
32
|
+
masterKeyWraps: [passwordWrap, ...recoveryWraps],
|
|
33
|
+
});
|
|
34
|
+
return { recoveryCodes };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Gates a mailbox's normal content behind one-time E2E encryption key setup, per
|
|
38
|
+
* `specs/end-to-end_encryption.md`'s "Generate signing and encryption keypairs on the client's device on
|
|
39
|
+
* first sign-in." Rendered by `MailShell` wrapping its own returned content unconditionally, not only
|
|
40
|
+
* once a mailbox is resolved — mounting this component at a *different* tree position depending on
|
|
41
|
+
* `mailboxUid`'s readiness (e.g. only wrapping once ready, rendering bare content beforehand) would make
|
|
42
|
+
* `children` (real `AppShell` chrome) itself remount the moment `mailboxUid` resolves, tearing down any
|
|
43
|
+
* state/effects it had already started. Always wrapping keeps `AppShell` mounted continuously across
|
|
44
|
+
* that transition; this component simply passes `children` through untouched while `mailboxUid` is
|
|
45
|
+
* still unresolved, and only starts its own check once a real `mailboxUid` is supplied.
|
|
46
|
+
*
|
|
47
|
+
* A `getKeyVault()` failure (network error, server not yet wired up to serve this endpoint) also renders
|
|
48
|
+
* `children` unchanged rather than blocking mail entirely - encryption is optional and gradual by design
|
|
49
|
+
* (the spec's own "near zero frictionless experience" goal), not a hard prerequisite for reading mail.
|
|
50
|
+
*/
|
|
51
|
+
export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxKeys, children }) {
|
|
52
|
+
const [status, setStatus] = useState(mailboxUid ? "checking" : "ready");
|
|
53
|
+
const [password, setPassword] = useState("");
|
|
54
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
55
|
+
const [error, setError] = useState(null);
|
|
56
|
+
const [recoveryCodes, setRecoveryCodes] = useState([]);
|
|
57
|
+
const [codesSaved, setCodesSaved] = useState(false);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!mailboxUid) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// A remount within the same browser session (e.g. navigating between pages) shouldn't re-prompt
|
|
63
|
+
// for a password the in-memory session store (`keySession.ts`) already has unwrapped - only a
|
|
64
|
+
// fresh session (reload, new tab, post-logout) has nothing there.
|
|
65
|
+
if (getUnlockedKeys(mailboxUid)) {
|
|
66
|
+
setStatus("ready");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
let cancelled = false;
|
|
70
|
+
getKeyVault(mailboxUid)
|
|
71
|
+
.then((vault) => {
|
|
72
|
+
if (!cancelled) {
|
|
73
|
+
setStatus(vault.wrappedKeys.length > 0 ? "unlock" : "setup_password");
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.catch(() => {
|
|
77
|
+
if (!cancelled) {
|
|
78
|
+
setStatus("ready");
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return () => {
|
|
82
|
+
cancelled = true;
|
|
83
|
+
};
|
|
84
|
+
}, [mailboxUid]);
|
|
85
|
+
async function handleUnlock(e) {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
setError(null);
|
|
88
|
+
setStatus("unlocking");
|
|
89
|
+
try {
|
|
90
|
+
// Only reachable via "unlock", which the effect above only ever sets once mailboxUid was
|
|
91
|
+
// defined.
|
|
92
|
+
await unlockWithPassword(mailboxUid, mailboxKeys ?? [], password);
|
|
93
|
+
setStatus("ready");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Deliberately generic - see `unlockWithPassword()`'s own doc comment: it throws the same way
|
|
97
|
+
// for "no password wrap enrolled" and "wrong password" today, and this UI has no way to tell
|
|
98
|
+
// those apart without leaking which is which to a potential attacker guessing passwords.
|
|
99
|
+
setError("Incorrect password.");
|
|
100
|
+
setStatus("unlock");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function handleSetPassword(e) {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
106
|
+
setError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (password !== confirmPassword) {
|
|
110
|
+
setError("Passwords do not match.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
setError(null);
|
|
114
|
+
setStatus("enrolling");
|
|
115
|
+
try {
|
|
116
|
+
// Only reachable via "setup_password", which the effect above only ever sets once mailboxUid
|
|
117
|
+
// was defined (and mailboxAddress necessarily came with it - see KeyEnrollmentGateProps).
|
|
118
|
+
const { recoveryCodes: codes } = await provisionEncryptionKey(mailboxUid, mailboxAddress, password);
|
|
119
|
+
setRecoveryCodes(codes);
|
|
120
|
+
setStatus("show_recovery_codes");
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not set up encryption for this mailbox.");
|
|
124
|
+
setStatus("setup_password");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (status === "checking") {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (status === "unlock" || status === "unlocking") {
|
|
131
|
+
const unlocking = status === "unlocking";
|
|
132
|
+
return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Unlock your mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Enter your encryption password to unlock signing and reading protected mail this session." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleUnlock, children: [_jsx(FormField, { label: "Encryption password", htmlFor: "key-unlock-password", children: _jsx("input", { id: "key-unlock-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: unlocking, autoComplete: "current-password" }) }), _jsx(Button, { type: "submit", loading: unlocking, disabled: unlocking, children: "Unlock" })] })] }) }));
|
|
133
|
+
}
|
|
134
|
+
if (status === "setup_password" || status === "enrolling") {
|
|
135
|
+
const enrolling = status === "enrolling";
|
|
136
|
+
return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Protect your mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Choose a password to protect your encryption keys. This is separate from your sign-in password and is never sent to the server." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSetPassword, children: [_jsx(FormField, { label: "Encryption password", htmlFor: "key-enrollment-password", children: _jsx("input", { id: "key-enrollment-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(FormField, { label: "Confirm password", htmlFor: "key-enrollment-password-confirm", children: _jsx("input", { id: "key-enrollment-password-confirm", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(Button, { type: "submit", loading: enrolling, disabled: enrolling, children: "Continue" })] })] }) }));
|
|
137
|
+
}
|
|
138
|
+
if (status === "show_recovery_codes") {
|
|
139
|
+
return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Save your recovery codes" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "If you lose your password, these codes are the only way to recover your encrypted mail. Each code can be used once. Store them somewhere safe \u2014 they will not be shown again." }), _jsx("ul", { className: "grid grid-cols-2 gap-2 mb-5 font-mono text-sm", children: recoveryCodes.map((code) => (_jsx("li", { className: "bg-surface-alt rounded-sm py-1.5 px-2 text-center", children: code }, code))) }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: codesSaved, onChange: (e) => setCodesSaved(e.target.checked) }), "I have saved these recovery codes in a safe place."] }), _jsx(Button, { type: "button", disabled: !codesSaved, onClick: () => setStatus("ready"), children: "Continue" })] }) }));
|
|
140
|
+
}
|
|
141
|
+
return _jsx(_Fragment, { children: children });
|
|
142
|
+
}
|
|
@@ -16,7 +16,7 @@ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
|
16
16
|
* lightweight summary, since mounting a full `MessageDetailPane` (and its iframe) for every message in
|
|
17
17
|
* a long thread up front would be wasteful.
|
|
18
18
|
*/
|
|
19
|
-
export default function ConversationThreadPane({ conversation, folders }) {
|
|
19
|
+
export default function ConversationThreadPane({ conversation, folders, labels }) {
|
|
20
20
|
const [messages, setMessages] = useState({});
|
|
21
21
|
const [attachmentsByUid, setAttachmentsByUid] = useState({});
|
|
22
22
|
const [expandedUids, setExpandedUids] = useState(new Set());
|
|
@@ -107,6 +107,6 @@ export default function ConversationThreadPane({ conversation, folders }) {
|
|
|
107
107
|
// message in place (same as `onRecalled` above) rather than removing it from
|
|
108
108
|
// the conversation — `conversation.messageUids` is owned by the parent, not
|
|
109
109
|
// this component, so there's no membership list here to remove it from.
|
|
110
|
-
onScheduledSendCanceled: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), isInbox: folders.find((f) => f.uid === message.folderUid)?.type === "inbox", onClassified: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), onReceiptHandled: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })) })] }, uid));
|
|
110
|
+
onScheduledSendCanceled: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), isInbox: folders.find((f) => f.uid === message.folderUid)?.type === "inbox", onClassified: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), onReceiptHandled: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), onArchived: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })), labels: labels, onLabelsChanged: (updated) => setMessages((prev) => ({ ...prev, [uid]: updated })) })] }, uid));
|
|
111
111
|
})] }));
|
|
112
112
|
}
|
|
@@ -3,14 +3,34 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { useState } from "react";
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import DOMPurify from "dompurify";
|
|
7
8
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
-
import { approveReceipt, attachmentContentUrl, cancelScheduledSend, classifyMessage, declineReceipt, recallMessage, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
import { approveReceipt, archiveMessage, attachmentContentUrl, cancelScheduledSend, classifyMessage, declineReceipt, getMessageRawContent, recallMessage, setMessageLabels, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
10
|
import { buildForwardQuote, buildReplyQuote, forwardSubject, replySubject } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
11
|
+
import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
12
|
+
import { evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
|
|
13
|
+
import { isLikelyMailingList } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
10
14
|
import { useCompose } from "./compose/ComposeContext.js";
|
|
11
15
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
12
16
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
13
17
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
18
|
+
/** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
|
|
19
|
+
* plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
|
|
20
|
+
* read as a muted variant of "verified" (a failed signature is a stronger negative than no signature at
|
|
21
|
+
* all), and "Unprotected" MUST NOT read as an error - the class pairs below are chosen so those two
|
|
22
|
+
* never share styling with each other or with the verified states. */
|
|
23
|
+
const SECURITY_INDICATOR = {
|
|
24
|
+
unprotected: { label: "Unprotected", className: "bg-surface-alt text-text-muted" },
|
|
25
|
+
encrypted: { label: "Encrypted", className: "bg-primary/10 text-primary-dark" },
|
|
26
|
+
signed_verified: { label: "Signed & verified", className: "bg-success/10 text-success" },
|
|
27
|
+
encrypted_verified: { label: "Encrypted & verified", className: "bg-success/10 text-success" },
|
|
28
|
+
signature_failed: { label: "Signature failed", className: "bg-danger-bg text-danger" },
|
|
29
|
+
};
|
|
30
|
+
function SecurityIndicator({ state }) {
|
|
31
|
+
const { label, className } = SECURITY_INDICATOR[state];
|
|
32
|
+
return _jsx("span", { className: `text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`, children: label });
|
|
33
|
+
}
|
|
14
34
|
function formatBytes(bytes) {
|
|
15
35
|
if (bytes >= 1000000)
|
|
16
36
|
return `${(bytes / 1000000).toFixed(1)} MB`;
|
|
@@ -25,11 +45,16 @@ function formatBytes(bytes) {
|
|
|
25
45
|
* a message row), and `ConversationThreadPane` (one per expanded message in a thread) — see each call
|
|
26
46
|
* site for how `message`/`attachments`/`isSentItems` are sourced.
|
|
27
47
|
*/
|
|
28
|
-
export default function MessageDetailPane({ message, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, isInbox, onClassified, onReceiptHandled, }) {
|
|
48
|
+
export default function MessageDetailPane({ message, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, isInbox, onClassified, onReceiptHandled, onArchived, labels, onLabelsChanged, }) {
|
|
29
49
|
const { openCompose } = useCompose();
|
|
30
50
|
const [confirming, setConfirming] = useState(false);
|
|
31
51
|
const [recalling, setRecalling] = useState(false);
|
|
32
52
|
const [canceling, setCanceling] = useState(false);
|
|
53
|
+
const [archiving, setArchiving] = useState(false);
|
|
54
|
+
const [archiveError, setArchiveError] = useState(null);
|
|
55
|
+
const [labelsOpen, setLabelsOpen] = useState(false);
|
|
56
|
+
const [togglingLabelUid, setTogglingLabelUid] = useState(null);
|
|
57
|
+
const [labelsError, setLabelsError] = useState(null);
|
|
33
58
|
const [error, setError] = useState(null);
|
|
34
59
|
// Kept separate from `error` (the Recall flow's own state) since this renders inline in the main
|
|
35
60
|
// pane rather than inside a confirmation modal — the two flows never need to share one message.
|
|
@@ -42,6 +67,35 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
|
|
|
42
67
|
// wouldn't distinguish which row's buttons should show a loading state.
|
|
43
68
|
const [receiptBusy, setReceiptBusy] = useState(null);
|
|
44
69
|
const [receiptError, setReceiptError] = useState(null);
|
|
70
|
+
const [security, setSecurity] = useState(null);
|
|
71
|
+
// Evaluates every message's security state, not just ones flagged `encrypted` - a detached
|
|
72
|
+
// `multipart/signed` message needs its raw MIME read too (a sanitized HTML body never carries the
|
|
73
|
+
// signature part) to tell "Signed & verified" apart from plain "Unprotected". A fetch/parse failure
|
|
74
|
+
// degrades to "Unprotected" rather than surfacing an error - the spec treats that as this module's
|
|
75
|
+
// safe default, not a distinct failure state of its own.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!message) {
|
|
78
|
+
setSecurity(null);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
setSecurity(null);
|
|
83
|
+
getMessageRawContent(message.uid)
|
|
84
|
+
.then((rawMime) => evaluateMessageSecurity(rawMime, getUnlockedKeys(message.mailboxUid)))
|
|
85
|
+
.then((result) => {
|
|
86
|
+
if (!cancelled) {
|
|
87
|
+
setSecurity(result);
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
.catch(() => {
|
|
91
|
+
if (!cancelled) {
|
|
92
|
+
setSecurity({ state: "unprotected" });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
return () => {
|
|
96
|
+
cancelled = true;
|
|
97
|
+
};
|
|
98
|
+
}, [message?.uid]);
|
|
45
99
|
if (!message) {
|
|
46
100
|
return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a message to read it." });
|
|
47
101
|
}
|
|
@@ -55,6 +109,7 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
|
|
|
55
109
|
subject: replySubject(message.subject),
|
|
56
110
|
quotedHtml: buildReplyQuote(message),
|
|
57
111
|
signatureContext: "reply_forward",
|
|
112
|
+
suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
|
|
58
113
|
});
|
|
59
114
|
}
|
|
60
115
|
function handleReplyAll() {
|
|
@@ -66,6 +121,7 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
|
|
|
66
121
|
subject: replySubject(message.subject),
|
|
67
122
|
quotedHtml: buildReplyQuote(message),
|
|
68
123
|
signatureContext: "reply_forward",
|
|
124
|
+
suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
|
|
69
125
|
});
|
|
70
126
|
}
|
|
71
127
|
function handleForward() {
|
|
@@ -113,6 +169,43 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
|
|
|
113
169
|
setCanceling(false);
|
|
114
170
|
}
|
|
115
171
|
}
|
|
172
|
+
// Only ever invoked from the Archive button below, which itself only renders once `message` is
|
|
173
|
+
// loaded and this isn't a Drafts/Outbox message (the button's own guard mirrors
|
|
174
|
+
// `BaseMessageRoute.archive()`'s server-side 400) — same real invariant as `handleRecall` above.
|
|
175
|
+
async function handleArchive() {
|
|
176
|
+
setArchiving(true);
|
|
177
|
+
setArchiveError(null);
|
|
178
|
+
try {
|
|
179
|
+
const updated = await archiveMessage(message.uid);
|
|
180
|
+
onArchived?.(updated);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
setArchiveError(err instanceof ApiRequestError ? err.message : "Could not archive this message.");
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
setArchiving(false);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Only ever invoked from a checkbox in the Labels popover below, which itself only renders once
|
|
190
|
+
// `message` is loaded — auto-saves on every toggle (no separate "Save" step), computing the full
|
|
191
|
+
// new `labelUids` set from the message's current one since `setMessageLabels()` replaces the whole
|
|
192
|
+
// list rather than patching a single entry.
|
|
193
|
+
async function handleToggleLabel(labelUid) {
|
|
194
|
+
setTogglingLabelUid(labelUid);
|
|
195
|
+
setLabelsError(null);
|
|
196
|
+
try {
|
|
197
|
+
const current = message.labelUids ?? [];
|
|
198
|
+
const next = current.includes(labelUid) ? current.filter((uid) => uid !== labelUid) : [...current, labelUid];
|
|
199
|
+
const updated = await setMessageLabels(message, next);
|
|
200
|
+
onLabelsChanged?.(updated);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
setLabelsError(err instanceof ApiRequestError ? err.message : "Could not update this message's labels.");
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
setTogglingLabelUid(null);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
116
209
|
// Only ever invoked from the classification button below, which itself only renders once `message`
|
|
117
210
|
// is loaded and `isInbox` is true — the non-null assertion reflects the same real invariant as
|
|
118
211
|
// `handleRecall`/`handleCancelScheduledSend` above.
|
|
@@ -146,8 +239,14 @@ export default function MessageDetailPane({ message, attachments, backHref, isSe
|
|
|
146
239
|
setReceiptBusy(null);
|
|
147
240
|
}
|
|
148
241
|
}
|
|
149
|
-
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: [_jsx("h1", { className: "text-lg font-bold tracking-tight", children: message.subject || "(no subject)" }), isSentItems &&
|
|
150
|
-
(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" }))), isOutbox && 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" })] }))] }), cancelError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: cancelError }) })), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["From ", message.from.displayName || message.from.address, " \u00B7", " ", new Date(message.receivedDate).toLocaleString()] }), _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" })
|
|
242
|
+
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: message.subject || "(no subject)" }), security && _jsx(SecurityIndicator, { state: security.state })] }), isSentItems &&
|
|
243
|
+
(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" }))), isOutbox && 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" })] }))] }), cancelError && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: cancelError }) })), security?.headerTamperDetected && (_jsx("div", { className: "mt-2", children: _jsx(Alert, { children: "This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or encrypted \u2014 an intermediary may have altered them after sending. Treat the fields shown above with caution." }) })), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["From ", message.from.displayName || message.from.address, " \u00B7", " ", new Date(message.receivedDate).toLocaleString()] }), _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" }), !isOutbox && 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 && (message.labelUids?.length ?? 0) > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 mt-2", children: message
|
|
244
|
+
.labelUids.map((uid) => labels.find((l) => l.uid === uid))
|
|
245
|
+
.filter((l) => !!l)
|
|
246
|
+
.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"]
|
|
151
247
|
.filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
|
|
152
|
-
.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: [message.from.displayName || message.from.address, " 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 }) })), 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))) }))] }), _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" })] })] })
|
|
248
|
+
.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: [message.from.displayName || message.from.address, " 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 }) })), 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 && (_jsx("div", { className: "px-4 pt-2", children: _jsx(Alert, { children: security.decryptError }) })), security?.html !== undefined ? (_jsx("iframe", { title: message.subject || "Message content", srcDoc: DOMPurify.sanitize(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" })] })] }), _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) => {
|
|
249
|
+
const checked = message.labelUids?.includes(l.uid) ?? false;
|
|
250
|
+
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 === l.uid, 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));
|
|
251
|
+
}) })] })] }));
|
|
153
252
|
}
|
|
@@ -21,7 +21,7 @@ export function useCompose() {
|
|
|
21
21
|
export default function ComposeProvider({ children }) {
|
|
22
22
|
const [sessions, setSessions] = useState([]);
|
|
23
23
|
const isMobile = useIsMobile();
|
|
24
|
-
function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new" }) {
|
|
24
|
+
function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning }) {
|
|
25
25
|
setSessions((prev) => [
|
|
26
26
|
...prev,
|
|
27
27
|
{
|
|
@@ -32,6 +32,7 @@ export default function ComposeProvider({ children }) {
|
|
|
32
32
|
initialSubject: subject,
|
|
33
33
|
initialQuotedHtml: quotedHtml,
|
|
34
34
|
signatureContext,
|
|
35
|
+
suppressSigning,
|
|
35
36
|
minimized: false,
|
|
36
37
|
},
|
|
37
38
|
]);
|