@rapidmx/web-client 0.3.1 → 0.5.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/admin/audit-log/index.tsx +35 -8
- package/apps/admin/branding/index.tsx +39 -404
- package/apps/admin/data-requests/index.tsx +480 -485
- package/apps/admin/distribution-lists/[uid].tsx +19 -1
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/escrow-scopes/[uid].tsx +348 -237
- package/apps/admin/escrow-scopes/new/index.tsx +8 -2
- package/apps/admin/index.tsx +118 -82
- package/apps/admin/ingest-queue/index.tsx +44 -5
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/[uid].tsx +211 -184
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/quarantine/index.tsx +46 -14
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/admin/transport-rules/[uid].tsx +30 -1
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
- package/apps/admin/transport-rules/index.tsx +24 -6
- package/apps/admin/transport-rules/new/index.tsx +30 -1
- package/apps/escrow/_layout.tsx +2 -3
- package/apps/escrow/audit-log/index.tsx +196 -168
- package/apps/escrow/matters/[uid].tsx +621 -521
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
- package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
- package/apps/shared/components/admin/signOut.ts +79 -0
- package/apps/shared/components/admin/usePagedList.tsx +129 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +717 -513
- package/apps/shared/components/calendar/MonthView.tsx +173 -139
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
- package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
- package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
- package/apps/shared/components/calendar/allDay.ts +124 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
- package/apps/shared/components/contacts/ContactForm.tsx +377 -314
- package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
- package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
- package/apps/shared/components/contacts/contactKeys.ts +78 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
- package/apps/shared/components/layout/AppShell.tsx +270 -169
- package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
- package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
- package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
- package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
- package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
- package/apps/shared/components/mail/pinnedSigners.ts +124 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -0
- package/apps/shared/components/mail/writableMailboxes.ts +101 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/mail/listAllPages.ts +39 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +316 -0
- package/apps/shared/search/localIndexSchema.ts +236 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexStorage.ts +98 -0
- package/apps/shared/search/localIndexVFS.ts +266 -0
- package/apps/shared/search/localIndexWorker.ts +926 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +438 -407
- package/apps/www/contacts/[uid].tsx +21 -2
- package/apps/www/contacts/index.tsx +148 -17
- package/apps/www/index.tsx +1314 -486
- package/apps/www/messages/[uid].tsx +23 -7
- package/apps/www/settings/auto-reply/index.tsx +134 -129
- package/apps/www/settings/booking-types/[uid].tsx +306 -300
- package/apps/www/settings/encryption/index.tsx +800 -215
- package/apps/www/settings/filters/[uid].tsx +171 -155
- package/apps/www/settings/filters/new/index.tsx +8 -1
- package/apps/www/settings/focused-inbox/index.tsx +150 -148
- package/apps/www/settings/privacy/index.tsx +494 -436
- package/apps/www/settings/read-receipts/index.tsx +148 -125
- package/apps/www/settings/sharing/index.tsx +274 -0
- package/apps/www/tasks/index.tsx +638 -530
- package/dist/apps/admin/audit-log/index.js +38 -8
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
- package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
- package/dist/apps/admin/index.js +17 -1
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/quarantine/index.js +20 -11
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/admin/transport-rules/[uid].js +18 -2
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
- package/dist/apps/admin/transport-rules/index.js +22 -6
- package/dist/apps/admin/transport-rules/new/index.js +18 -2
- package/dist/apps/escrow/_layout.js +3 -3
- package/dist/apps/escrow/audit-log/index.js +26 -1
- package/dist/apps/escrow/matters/[uid].js +77 -42
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
- package/dist/apps/shared/components/admin/signOut.js +75 -0
- package/dist/apps/shared/components/admin/usePagedList.js +99 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +154 -21
- package/dist/apps/shared/components/calendar/MonthView.js +12 -5
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
- package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
- package/dist/apps/shared/components/calendar/allDay.js +111 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
- package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
- package/dist/apps/shared/components/layout/AppShell.js +100 -8
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
- package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
- package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
- package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/mail/listAllPages.js +25 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +408 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
- package/dist/apps/shared/search/localIndexSchema.js +185 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.js +258 -0
- package/dist/apps/shared/search/localIndexWorker.js +687 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +38 -18
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.js +94 -15
- package/dist/apps/www/index.js +770 -137
- package/dist/apps/www/messages/[uid].js +23 -7
- package/dist/apps/www/settings/auto-reply/index.js +9 -4
- package/dist/apps/www/settings/booking-types/[uid].js +17 -12
- package/dist/apps/www/settings/encryption/index.js +541 -96
- package/dist/apps/www/settings/filters/[uid].js +22 -7
- package/dist/apps/www/settings/filters/new/index.js +8 -2
- package/dist/apps/www/settings/focused-inbox/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +57 -22
- package/dist/apps/www/settings/read-receipts/index.js +10 -3
- package/dist/apps/www/settings/sharing/index.js +124 -0
- package/dist/apps/www/tasks/index.js +76 -14
- package/package.json +3 -2
|
@@ -6,6 +6,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import { useState } from "react";
|
|
7
7
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
8
8
|
const INPUT_CLASS = "flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
9
|
+
/**
|
|
10
|
+
* Whether `conditions` holds at least one real condition: a list with a non-blank entry, a ticked
|
|
11
|
+
* boolean, or a chosen select value. A rule with none would match *every* message, so the pages that save
|
|
12
|
+
* a `RuleBuilder` value (mail filters, transport rules) must refuse to save unless this returns `true`.
|
|
13
|
+
*/
|
|
14
|
+
export function hasConditions(conditions) {
|
|
15
|
+
return Object.values(conditions).some((v) => {
|
|
16
|
+
if (Array.isArray(v))
|
|
17
|
+
return v.some((entry) => typeof entry !== "string" || entry.trim() !== "");
|
|
18
|
+
if (typeof v === "string")
|
|
19
|
+
return v.trim() !== "";
|
|
20
|
+
return v !== undefined && v !== null && v !== false;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
9
23
|
/**
|
|
10
24
|
* Shared condition-row editor and enabled/sequence/stop-processing chrome for a rule that matches
|
|
11
25
|
* conditions and runs actions in order — `TransportRule` (admin, org-wide) and `MailFilterRule`
|
|
@@ -19,7 +33,7 @@ const INPUT_CLASS = "flex-1 text-sm py-2 px-3 border border-border rounded-sm bg
|
|
|
19
33
|
* add-header/add-recipient vs. move/copy/delete/mark-read/forward) are different enough that one
|
|
20
34
|
* component special-casing both would be worse than this split.
|
|
21
35
|
*/
|
|
22
|
-
export default function RuleBuilder({ value, onChange, conditionFields, actionTypes, }) {
|
|
36
|
+
export default function RuleBuilder({ value, onChange, conditionFields, actionTypes, showValidation = false, }) {
|
|
23
37
|
const [draftText, setDraftText] = useState({});
|
|
24
38
|
const [newActionType, setNewActionType] = useState(actionTypes[0]?.value ?? "");
|
|
25
39
|
const conditions = value.conditions;
|
|
@@ -36,12 +50,22 @@ export default function RuleBuilder({ value, onChange, conditionFields, actionTy
|
|
|
36
50
|
patch({ conditions: { ...conditions, [key]: [...existing, text] } });
|
|
37
51
|
setDraftText({ ...draftText, [key]: "" });
|
|
38
52
|
}
|
|
53
|
+
// Only invoked from a chip's own "Remove" button, which only renders while `conditions[key]` holds
|
|
54
|
+
// that entry. Removing the last entry deletes the key rather than leaving an empty list behind.
|
|
39
55
|
function removeListEntry(key, entry) {
|
|
40
|
-
const
|
|
41
|
-
|
|
56
|
+
const remaining = conditions[key].filter((e) => e !== entry);
|
|
57
|
+
const next = { ...conditions };
|
|
58
|
+
if (remaining.length > 0) {
|
|
59
|
+
next[key] = remaining;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
delete next[key];
|
|
63
|
+
}
|
|
64
|
+
patch({ conditions: next });
|
|
42
65
|
}
|
|
43
66
|
function toggleBoolean(key) {
|
|
44
|
-
|
|
67
|
+
// Unticking stores `undefined` (no condition), not `false`.
|
|
68
|
+
patch({ conditions: { ...conditions, [key]: conditions[key] ? undefined : true } });
|
|
45
69
|
}
|
|
46
70
|
function setSelect(key, value) {
|
|
47
71
|
patch({ conditions: { ...conditions, [key]: value || undefined } });
|
|
@@ -58,14 +82,18 @@ export default function RuleBuilder({ value, onChange, conditionFields, actionTy
|
|
|
58
82
|
function removeAction(index) {
|
|
59
83
|
patch({ actions: value.actions.filter((_, i) => i !== index) });
|
|
60
84
|
}
|
|
61
|
-
return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-4", children: "Conditions" }), _jsx("div", { className: "flex flex-col gap-4", children: conditionFields.map((field) => {
|
|
85
|
+
return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-4", children: "Conditions" }), !hasConditions(value.conditions) && (_jsx("p", { role: showValidation ? "alert" : undefined, className: `text-sm mb-4 ${showValidation ? "text-danger" : "text-text-muted"}`, children: "Add at least one condition. A rule without conditions would apply to every message." })), _jsx("div", { className: "flex flex-col gap-4", children: conditionFields.map((field) => {
|
|
62
86
|
if (field.kind === "boolean") {
|
|
63
87
|
return (_jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: !!conditions[field.key], onChange: () => toggleBoolean(field.key) }), field.label] }, field.key));
|
|
64
88
|
}
|
|
65
89
|
if (field.kind === "select") {
|
|
66
90
|
return (_jsxs("div", { children: [_jsx("label", { className: "text-sm font-semibold mb-1.5 block", htmlFor: `condition-${field.key}`, children: field.label }), _jsxs("select", { id: `condition-${field.key}`, className: INPUT_CLASS, value: conditions[field.key] ?? "", onChange: (e) => setSelect(field.key, e.target.value), children: [_jsx("option", { value: "", children: "Any" }), (field.options ?? []).map((opt) => (_jsx("option", { value: opt.value, children: opt.label }, opt.value)))] })] }, field.key));
|
|
67
91
|
}
|
|
68
|
-
return (_jsxs("div", { children: [_jsx("div", { className: "text-sm font-semibold mb-1.5", children: field.label }), _jsx("div", { className: "flex flex-wrap gap-2 mb-2", children: (conditions[field.key] ?? []).map((entry) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs py-1 px-2.5 rounded-pill bg-surface-alt", children: [entry, _jsx("button", { type: "button", "aria-label": `Remove ${entry}`, onClick: () => removeListEntry(field.key, entry), className: "text-text-muted hover:text-danger", children: "\u00D7" })] }, entry))) }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "text", "aria-label": field.label, className: INPUT_CLASS, placeholder: field.placeholder ?? "Add a value", value: draftText[field.key] ?? "", onChange: (e) => setDraftText({ ...draftText, [field.key]: e.target.value }),
|
|
92
|
+
return (_jsxs("div", { children: [_jsx("div", { className: "text-sm font-semibold mb-1.5", children: field.label }), _jsx("div", { className: "flex flex-wrap gap-2 mb-2", children: (conditions[field.key] ?? []).map((entry) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs py-1 px-2.5 rounded-pill bg-surface-alt", children: [entry, _jsx("button", { type: "button", "aria-label": `Remove ${entry}`, onClick: () => removeListEntry(field.key, entry), className: "text-text-muted hover:text-danger", children: "\u00D7" })] }, entry))) }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "text", "aria-label": field.label, className: INPUT_CLASS, placeholder: field.placeholder ?? "Add a value", value: draftText[field.key] ?? "", onChange: (e) => setDraftText({ ...draftText, [field.key]: e.target.value }),
|
|
93
|
+
// Text typed but never added (no Add/Enter) is committed when the field
|
|
94
|
+
// loses focus - e.g. to the page's Save button - so it's never silently
|
|
95
|
+
// dropped from the saved rule.
|
|
96
|
+
onBlur: () => addListEntry(field.key), onKeyDown: (e) => {
|
|
69
97
|
if (e.key === "Enter") {
|
|
70
98
|
e.preventDefault();
|
|
71
99
|
addListEntry(field.key);
|
|
@@ -22,6 +22,7 @@ export const SETTINGS_SECTIONS = [
|
|
|
22
22
|
{ id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
|
|
23
23
|
{ id: "booking-types", href: "/settings/booking-types", label: "Booking Links" },
|
|
24
24
|
{ id: "encryption", href: "/settings/encryption", label: "Encryption" },
|
|
25
|
+
{ id: "sharing", href: "/settings/sharing", label: "Sharing" },
|
|
25
26
|
{ id: "privacy", href: "/settings/privacy", label: "Privacy & Data" },
|
|
26
27
|
];
|
|
27
28
|
const SettingsShellContext = createContext({ mailboxes: [] });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
6
|
+
/** The uid of a mailbox's well-known folder of `type` (e.g. its Contacts or Tasks folder), for creating an
|
|
7
|
+
* item in a mailbox other than the one an app currently has loaded. Resolves `undefined` if the mailbox has
|
|
8
|
+
* no such folder; rejects if the folder list can't be fetched. */
|
|
9
|
+
export async function findWellKnownFolderUid(mailboxUid, type) {
|
|
10
|
+
const folders = await listFolders(mailboxUid);
|
|
11
|
+
return folders.find((folder) => folder.type === type)?.uid;
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** Page size the list pages (Contacts, Tasks) request per call - restapi caps `limit` at 500. */
|
|
6
|
+
export const LIST_PAGE_SIZE = 500;
|
|
7
|
+
/** Safety cap on pages fetched - stops a server that ignores `page` (and keeps returning a full first
|
|
8
|
+
* page) from looping forever. 40 pages x 500 = 20,000 items. */
|
|
9
|
+
export const MAX_LIST_PAGES = 40;
|
|
10
|
+
/**
|
|
11
|
+
* Fetches every page of a zero-based `limit`/`page` list endpoint, stopping at the first short page
|
|
12
|
+
* (fewer than `pageSize` items) - so a folder with more items than one page holds is shown in full
|
|
13
|
+
* rather than silently cut off at the first page. Stops at `maxPages` regardless, reporting `truncated`.
|
|
14
|
+
*/
|
|
15
|
+
export async function listAllPages(fetchPage, pageSize = LIST_PAGE_SIZE, maxPages = MAX_LIST_PAGES) {
|
|
16
|
+
const items = [];
|
|
17
|
+
for (let page = 0; page < maxPages; page++) {
|
|
18
|
+
const batch = await fetchPage(page);
|
|
19
|
+
items.push(...batch);
|
|
20
|
+
if (batch.length < pageSize) {
|
|
21
|
+
return { items, truncated: false };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { items, truncated: true };
|
|
25
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Mounts the Tier 2 local index's lifecycle: starts the background build for the active mailbox once it's
|
|
7
|
+
* unlocked, and destroys an index on the same events that destroy that mailbox's unlocked keys (spec §11
|
|
8
|
+
* "MUST be destroyed on the same events that destroy private keys: explicit logout, session revocation,
|
|
9
|
+
* and the configurable idle timeout").
|
|
10
|
+
*
|
|
11
|
+
* **Why polling, not hooking the existing destroy call sites directly**: `destroyUnlockedKeys()`
|
|
12
|
+
* (`react-shared/src/crypto/keySession.ts`) has no observer/callback hook. Watching `getUnlockedKeys()`
|
|
13
|
+
* for a present-to-absent transition catches *every* destroy path uniformly - idle timeout, the manual
|
|
14
|
+
* button, or anything else that calls it - without needing to know which one fired.
|
|
15
|
+
*
|
|
16
|
+
* Every mailbox this component has seen unlocked is tracked for the life of the mount - not just whichever
|
|
17
|
+
* is active right now - so switching mailboxes (or a folders-list refresh re-running the build effect)
|
|
18
|
+
* can never lose a pending unlocked-to-locked transition for a mailbox whose index was already built.
|
|
19
|
+
*
|
|
20
|
+
* **Explicit logout** navigates away immediately, which may not leave time for a poll tick - `AppShell.tsx`
|
|
21
|
+
* handles that case itself via `destroyAllLocalIndexes()`.
|
|
22
|
+
*/
|
|
23
|
+
import { useEffect, useRef } from "react";
|
|
24
|
+
import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
25
|
+
import { buildLocalIndex, cancelLocalIndexBuild } from "./localIndexBuilder.js";
|
|
26
|
+
import { destroyLocalIndex, pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
|
|
27
|
+
export const POLL_INTERVAL_MS = 5000;
|
|
28
|
+
/** Renders nothing - pure side-effect component, mounted by `MailShell.tsx`. */
|
|
29
|
+
export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMailboxUids }) {
|
|
30
|
+
// mailboxUid -> whether it was unlocked at the last check. An entry exists for every mailbox whose
|
|
31
|
+
// build this component started; only ever changed by a real observed transition, never reset by an
|
|
32
|
+
// effect re-run.
|
|
33
|
+
const trackedRef = useRef(new Map());
|
|
34
|
+
// The latest props, for the long-lived polling interval below.
|
|
35
|
+
const latestRef = useRef({ mailboxUid, folders });
|
|
36
|
+
latestRef.current = { mailboxUid, folders };
|
|
37
|
+
function startBuildIfUnlocked(targetMailboxUid, targetFolders) {
|
|
38
|
+
const unlocked = getUnlockedKeys(targetMailboxUid);
|
|
39
|
+
if (!unlocked || targetFolders.length === 0 || trackedRef.current.get(targetMailboxUid)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
trackedRef.current.set(targetMailboxUid, true);
|
|
43
|
+
// Never an unhandled rejection - a broken local index is best-effort infrastructure, not a build
|
|
44
|
+
// the rest of the app depends on.
|
|
45
|
+
buildLocalIndex(targetMailboxUid, unlocked, targetFolders).catch(() => undefined);
|
|
46
|
+
}
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (mailboxUid) {
|
|
49
|
+
startBuildIfUnlocked(mailboxUid, folders);
|
|
50
|
+
}
|
|
51
|
+
}, [mailboxUid, folders]);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const interval = setInterval(() => {
|
|
54
|
+
for (const [trackedUid, wasUnlocked] of trackedRef.current) {
|
|
55
|
+
if (wasUnlocked && !getUnlockedKeys(trackedUid)) {
|
|
56
|
+
// A present-to-absent transition: something just destroyed this mailbox's keys.
|
|
57
|
+
trackedRef.current.set(trackedUid, false);
|
|
58
|
+
// Stop the pass still running with the now-destroyed keys; the destroy itself also makes
|
|
59
|
+
// the Worker reject anything that pass still sends.
|
|
60
|
+
void cancelLocalIndexBuild(trackedUid);
|
|
61
|
+
void destroyLocalIndex(trackedUid);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// The active mailbox was (re-)unlocked since the last check, e.g. via an on-demand unlock prompt.
|
|
65
|
+
const { mailboxUid: activeUid, folders: activeFolders } = latestRef.current;
|
|
66
|
+
if (activeUid) {
|
|
67
|
+
startBuildIfUnlocked(activeUid, activeFolders);
|
|
68
|
+
}
|
|
69
|
+
}, POLL_INTERVAL_MS);
|
|
70
|
+
return () => clearInterval(interval);
|
|
71
|
+
}, []);
|
|
72
|
+
// A stable string key so a re-render with an equal-but-new array doesn't re-scan OPFS.
|
|
73
|
+
const accessibleKey = accessibleMailboxUids && accessibleMailboxUids.length > 0 ? JSON.stringify([...accessibleMailboxUids].sort()) : "";
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (accessibleKey) {
|
|
76
|
+
void pruneInaccessibleLocalIndexes(JSON.parse(accessibleKey));
|
|
77
|
+
}
|
|
78
|
+
}, [accessibleKey]);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The actual AES-256-GCM block cipher `localIndexVFS.ts`'s `EncryptingVFS` uses to encrypt/decrypt one
|
|
7
|
+
* physical storage block - deliberately split out from that file so it can be exercised with real
|
|
8
|
+
* WebCrypto in a plain unit test, with no OPFS/Worker/wa-sqlite involved at all (none of those are
|
|
9
|
+
* available under Vitest's jsdom environment, matching the same "node" test environment convention
|
|
10
|
+
* `react-shared`'s other crypto modules already use for exactly this reason).
|
|
11
|
+
*
|
|
12
|
+
* See `localIndexVFS.ts`'s own doc comment for the full design rationale (page layout, why a fresh
|
|
13
|
+
* random nonce per write, why AAD binds to filename+block index). This module is pure mechanism; that
|
|
14
|
+
* one is where the design is explained.
|
|
15
|
+
*/
|
|
16
|
+
/** The logical SQLite page size `EncryptingVFS` is designed for - MUST match the `PRAGMA page_size` the
|
|
17
|
+
* database was created with (`localIndexWorker.ts` sets this before the first write). */
|
|
18
|
+
export const LOGICAL_BLOCK_SIZE = 4096;
|
|
19
|
+
const NONCE_LENGTH = 12;
|
|
20
|
+
const TAG_LENGTH = 16;
|
|
21
|
+
/** One physical block on disk: `nonce || ciphertext || tag`. */
|
|
22
|
+
export const PHYSICAL_BLOCK_SIZE = LOGICAL_BLOCK_SIZE + NONCE_LENGTH + TAG_LENGTH;
|
|
23
|
+
/** Thrown when a stored block fails AES-GCM authentication - a wrong/rotated key, on-disk corruption, or
|
|
24
|
+
* a block moved to the wrong position (see `localIndexVFS.ts`'s doc comment on why AAD binds position).
|
|
25
|
+
* The worker's caller treats this identically to a schema-version mismatch: discard the whole index and
|
|
26
|
+
* rebuild (spec §11 "Invalidation"). */
|
|
27
|
+
export class PageCorruptedError extends Error {
|
|
28
|
+
constructor(filename, blockIndex, cause) {
|
|
29
|
+
super(`Local search index block ${blockIndex} of '${filename}' failed to decrypt.`);
|
|
30
|
+
this.cause = cause;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function importAesGcmKey(rawKey) {
|
|
34
|
+
return crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
35
|
+
}
|
|
36
|
+
function buildBlockAad(filename, blockIndex) {
|
|
37
|
+
return new TextEncoder().encode(`rapidmx-local-index-block:${filename}:${blockIndex}`);
|
|
38
|
+
}
|
|
39
|
+
/** Encrypts one logical block (`plaintext`, exactly `LOGICAL_BLOCK_SIZE` bytes) into its physical,
|
|
40
|
+
* on-disk representation (`PHYSICAL_BLOCK_SIZE` bytes: a fresh random nonce followed by
|
|
41
|
+
* ciphertext+tag). */
|
|
42
|
+
export async function encryptBlock(key, filename, blockIndex, plaintext) {
|
|
43
|
+
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
|
|
44
|
+
const ciphertextAndTag = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: buildBlockAad(filename, blockIndex) }, key, plaintext));
|
|
45
|
+
const physical = new Uint8Array(PHYSICAL_BLOCK_SIZE);
|
|
46
|
+
physical.set(nonce, 0);
|
|
47
|
+
physical.set(ciphertextAndTag, NONCE_LENGTH);
|
|
48
|
+
return physical;
|
|
49
|
+
}
|
|
50
|
+
/** Inverse of `encryptBlock()`. Throws `PageCorruptedError` (never a raw `DOMException`) on auth
|
|
51
|
+
* failure - a wrong key, tampered ciphertext, or a block swapped into the wrong position (caught by the
|
|
52
|
+
* position-bound AAD not matching). */
|
|
53
|
+
export async function decryptBlock(key, filename, blockIndex, physical) {
|
|
54
|
+
const nonce = physical.subarray(0, NONCE_LENGTH);
|
|
55
|
+
const ciphertextAndTag = physical.subarray(NONCE_LENGTH, PHYSICAL_BLOCK_SIZE);
|
|
56
|
+
try {
|
|
57
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: buildBlockAad(filename, blockIndex) }, key, ciphertextAndTag);
|
|
58
|
+
return new Uint8Array(plaintext);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
throw new PageCorruptedError(filename, blockIndex, err);
|
|
62
|
+
}
|
|
63
|
+
}
|