@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
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
const flushers = new Set();
|
|
6
|
+
let signingOut = false;
|
|
7
|
+
/**
|
|
8
|
+
* Marks the app as signing out (explicitly, or forced by a sign-out in another tab), so compose windows stop
|
|
9
|
+
* asking the browser to confirm leaving the page - the "Leave site?" prompt would otherwise let the user cancel
|
|
10
|
+
* the sign-out's own navigation and stay on a page whose session is being ended. Call it before flushing drafts
|
|
11
|
+
* and navigating. `clearSigningOut()` undoes it (a sign-out that didn't leave the page after all).
|
|
12
|
+
*/
|
|
13
|
+
export function markSigningOut() {
|
|
14
|
+
signingOut = true;
|
|
15
|
+
}
|
|
16
|
+
/** Undoes `markSigningOut()`. */
|
|
17
|
+
export function clearSigningOut() {
|
|
18
|
+
signingOut = false;
|
|
19
|
+
}
|
|
20
|
+
/** Whether `markSigningOut()` has been called (and not cleared). */
|
|
21
|
+
export function isSigningOut() {
|
|
22
|
+
return signingOut;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Registers an open compose window's draft flush, so leaving the app on purpose (Sign Out) can save the
|
|
26
|
+
* last edits still waiting on the autosave debounce before the session ends. Returns the unregister
|
|
27
|
+
* function. Module-level rather than React context: `AppShell` (the sign-out handler) renders the
|
|
28
|
+
* `ComposeProvider` itself, so it can't read that provider's context.
|
|
29
|
+
*/
|
|
30
|
+
export function registerComposeFlush(flush) {
|
|
31
|
+
flushers.add(flush);
|
|
32
|
+
return () => {
|
|
33
|
+
flushers.delete(flush);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Runs every registered flush and waits for all of them, but no longer than `timeoutMs`. Resolves to `true`
|
|
38
|
+
* only when every window reported its content saved in time (a window whose save failed also shows the user
|
|
39
|
+
* why, with Discard / Keep editing). Never rejects.
|
|
40
|
+
*/
|
|
41
|
+
export async function flushComposeDrafts(timeoutMs) {
|
|
42
|
+
let timer;
|
|
43
|
+
const timeout = new Promise((resolve) => {
|
|
44
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
45
|
+
});
|
|
46
|
+
const all = Promise.all([...flushers].map((flush) => flush().catch(() => false))).then((results) => results.every((result) => result !== false));
|
|
47
|
+
const saved = await Promise.race([all, timeout]);
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
return saved;
|
|
50
|
+
}
|
|
@@ -14,7 +14,17 @@ import AppShell from "../../layout/AppShell.js";
|
|
|
14
14
|
import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
|
|
15
15
|
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
16
16
|
import { useCompose } from "../compose/ComposeContext.js";
|
|
17
|
-
|
|
17
|
+
import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
|
|
18
|
+
/** The well-known mail folder types an "aggregate" pseudo-folder can merge across every accessible
|
|
19
|
+
* mailbox - a fixed, small set: `outbox` is deliberately excluded (transient per-mailbox send-queue
|
|
20
|
+
* state, not a "merge across mailboxes" concept), and there's no aggregate concept for `calendar`/
|
|
21
|
+
* `contacts`/`tasks`/`notes`/`user` folders (Mail's own sidebar already excludes those - see
|
|
22
|
+
* `MAIL_FOLDER_TYPES`). */
|
|
23
|
+
export const AGGREGATE_FOLDER_TYPES = ["inbox", "sent_items", "drafts", "deleted_items", "junk"];
|
|
24
|
+
function isAggregateFolderType(value) {
|
|
25
|
+
return !!value && AGGREGATE_FOLDER_TYPES.includes(value);
|
|
26
|
+
}
|
|
27
|
+
const MailShellContext = createContext({ mailboxes: [], mailboxFolders: [] });
|
|
18
28
|
/** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
|
|
19
29
|
export function useMailShell() {
|
|
20
30
|
return useContext(MailShellContext);
|
|
@@ -41,6 +51,14 @@ function folderSortKey(folder) {
|
|
|
41
51
|
const idx = FOLDER_ORDER.indexOf(folder.type);
|
|
42
52
|
return idx === -1 ? FOLDER_ORDER.length : idx;
|
|
43
53
|
}
|
|
54
|
+
function sortedFoldersOf(folders) {
|
|
55
|
+
return [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name));
|
|
56
|
+
}
|
|
57
|
+
function aggregateUnreadCount(mailboxFolders, type) {
|
|
58
|
+
return mailboxFolders.reduce((total, mf) => total + (mf.folders.find((f) => f.type === type)?.unreadCount ?? 0), 0);
|
|
59
|
+
}
|
|
60
|
+
/** Page size of the one `listMailboxes()` call this shell makes. */
|
|
61
|
+
export const MAILBOX_LIST_LIMIT = 100;
|
|
44
62
|
/**
|
|
45
63
|
* A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
|
|
46
64
|
* `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
|
|
@@ -48,39 +66,51 @@ function folderSortKey(folder) {
|
|
|
48
66
|
* own function body would see only whatever context exists *above* `MailShell`, never a provider one of
|
|
49
67
|
* its own descendants creates. This button, rendered as part of `AppShell`'s `children`, sits correctly
|
|
50
68
|
* inside that subtree.
|
|
69
|
+
*
|
|
70
|
+
* Opens with no mailbox: a fresh message defaults to the caller's own mailbox, and the compose window's
|
|
71
|
+
* own From field is where the sender is chosen.
|
|
51
72
|
*/
|
|
52
|
-
function ComposeButton(
|
|
73
|
+
function ComposeButton() {
|
|
53
74
|
const { openCompose } = useCompose();
|
|
54
|
-
return (_jsx("button", { type: "button", onClick: () => openCompose({
|
|
75
|
+
return (_jsx("button", { type: "button", onClick: () => openCompose({}), className: "block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark", children: "Compose" }));
|
|
55
76
|
}
|
|
56
77
|
/**
|
|
57
|
-
* Mail's own contextual sidebar (mailbox
|
|
58
|
-
* `AppShell` chrome (icon rail, header,
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
78
|
+
* Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
|
|
79
|
+
* aggregate section) + content area, rendered inside the shared `AppShell` chrome (icon rail, header,
|
|
80
|
+
* impersonation banner — see that component). There is no client-side router in this framework (see
|
|
81
|
+
* `ReactRoute`'s file-convention resolver) — the selected mailbox/folder (or aggregate pseudo-folder) live
|
|
82
|
+
* in the URL's `?mailboxUid=`/`?folderUid=`/`?aggregate=` query params, read once on mount (never during
|
|
83
|
+
* the initial render itself, matching every other query-param reader in this codebase — e.g.
|
|
84
|
+
* `apps/admin/quarantine`'s `readMailboxUid()` — so the server-rendered and just-hydrated client markup
|
|
85
|
+
* match).
|
|
86
|
+
*
|
|
87
|
+
* Every accessible mailbox's folder tree renders at once - there is no "switch mailbox" affordance
|
|
88
|
+
* anymore (the mailbox `<select>` this shell used to have is gone) - so a single-mailbox user sees zero
|
|
89
|
+
* behavior change from before this, and a multi-mailbox user sees every mailbox's folders (and the new
|
|
90
|
+
* aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
|
|
91
|
+
* of folders" requirement.
|
|
63
92
|
*/
|
|
64
93
|
export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
|
|
65
94
|
const [status, setStatus] = useState("checking");
|
|
66
95
|
const [error, setError] = useState(null);
|
|
67
96
|
const [mailboxes, setMailboxes] = useState([]);
|
|
68
|
-
const [
|
|
97
|
+
const [mailboxFolders, setMailboxFolders] = useState([]);
|
|
69
98
|
const [foldersLoading, setFoldersLoading] = useState(true);
|
|
70
|
-
const [folderError, setFolderError] = useState(null);
|
|
71
99
|
const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
|
|
72
100
|
const [requestedFolderUid, setRequestedFolderUid] = useState(null);
|
|
101
|
+
const [requestedAggregateType, setRequestedAggregateType] = useState(null);
|
|
73
102
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
74
103
|
useEffect(() => {
|
|
75
104
|
const params = new URLSearchParams(window.location.search);
|
|
76
105
|
setRequestedMailboxUid(params.get("mailboxUid"));
|
|
77
106
|
setRequestedFolderUid(params.get("folderUid"));
|
|
107
|
+
setRequestedAggregateType(params.get("aggregate"));
|
|
78
108
|
}, []);
|
|
79
109
|
useEffect(() => {
|
|
80
110
|
if (!userUid) {
|
|
81
111
|
return;
|
|
82
112
|
}
|
|
83
|
-
listMailboxes({ limit:
|
|
113
|
+
listMailboxes({ limit: MAILBOX_LIST_LIMIT })
|
|
84
114
|
.then((result) => {
|
|
85
115
|
setMailboxes(result);
|
|
86
116
|
setStatus("ready");
|
|
@@ -90,27 +120,54 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
90
120
|
setStatus("error");
|
|
91
121
|
});
|
|
92
122
|
}, [userUid]);
|
|
93
|
-
const
|
|
94
|
-
|
|
123
|
+
const aggregateFolderType = isAggregateFolderType(requestedAggregateType)
|
|
124
|
+
? requestedAggregateType
|
|
125
|
+
: undefined;
|
|
126
|
+
const mailboxUid = aggregateFolderType
|
|
127
|
+
? undefined
|
|
128
|
+
: (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
|
|
129
|
+
mailboxes[0]?.uid;
|
|
130
|
+
// Fans out one listFolders() call per accessible mailbox in parallel - each call catches its own
|
|
131
|
+
// failure into an MailboxFolders.error rather than letting Promise.all reject, so one mailbox's fetch
|
|
132
|
+
// failure renders that section's own inline Alert instead of blanking out every other mailbox's
|
|
133
|
+
// folder tree.
|
|
95
134
|
useEffect(() => {
|
|
96
|
-
if (
|
|
97
|
-
|
|
135
|
+
if (mailboxes.length === 0) {
|
|
136
|
+
// Deliberately leaves foldersLoading as-is: this also runs once before listMailboxes() has
|
|
137
|
+
// resolved, and clearing it here would briefly render an empty sidebar the moment mailboxes
|
|
138
|
+
// arrive, before their folders do. A genuinely mailbox-less caller gets MailboxProvisioning.
|
|
139
|
+
setMailboxFolders([]);
|
|
98
140
|
return;
|
|
99
141
|
}
|
|
100
|
-
setFolderError(null);
|
|
101
142
|
setFoldersLoading(true);
|
|
102
|
-
listFolders(
|
|
103
|
-
.then((result) =>
|
|
104
|
-
.catch((err) =>
|
|
143
|
+
void Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
|
|
144
|
+
.then((result) => ({ mailbox, folders: result.filter((f) => MAIL_FOLDER_TYPES.has(f.type)) }))
|
|
145
|
+
.catch((err) => ({
|
|
146
|
+
mailbox,
|
|
147
|
+
folders: [],
|
|
148
|
+
error: err instanceof ApiRequestError ? err.message : "Could not load folders.",
|
|
149
|
+
}))))
|
|
150
|
+
.then(setMailboxFolders)
|
|
105
151
|
.finally(() => setFoldersLoading(false));
|
|
106
|
-
}, [
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
//
|
|
113
|
-
|
|
152
|
+
}, [mailboxes]);
|
|
153
|
+
const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
|
|
154
|
+
const folderUid = aggregateFolderType
|
|
155
|
+
? undefined
|
|
156
|
+
: (requestedFolderUid && selectedMailboxFolders.some((f) => f.uid === requestedFolderUid) ? requestedFolderUid : undefined) ??
|
|
157
|
+
selectedMailboxFolders.find((f) => f.type === "inbox")?.uid;
|
|
158
|
+
// The mailbox `KeyEnrollmentGate`/`LocalIndexLifecycle`/`ComposeButton` treat as "the" mailbox when
|
|
159
|
+
// there's no single selected one to use (aggregate mode) - the caller's own owned mailbox if they
|
|
160
|
+
// have one, else whichever accessible mailbox happens to be first. See MailShell's own doc comment on
|
|
161
|
+
// this being an accepted limitation: an aggregate-view message from a *different*, not-yet-visited
|
|
162
|
+
// mailbox may still need that mailbox's own folder view opened directly to unlock/decrypt it.
|
|
163
|
+
const defaultMailboxUid = mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
|
|
164
|
+
const activeMailboxUid = mailboxUid ?? defaultMailboxUid;
|
|
165
|
+
const contextValue = useMemo(() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders }), [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders]);
|
|
166
|
+
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
|
|
167
|
+
// mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all. Checks
|
|
168
|
+
// `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
|
|
169
|
+
// aggregate mode even with mailboxes present.
|
|
170
|
+
if (userUid && status === "ready" && mailboxes.length === 0) {
|
|
114
171
|
return _jsx(MailboxProvisioning, {});
|
|
115
172
|
}
|
|
116
173
|
let inner = null;
|
|
@@ -125,17 +182,22 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
125
182
|
else if (userUid && status === "ready") {
|
|
126
183
|
// A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
|
|
127
184
|
// `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
|
|
128
|
-
// unmounted)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
185
|
+
// unmounted).
|
|
186
|
+
const sidebarContent = () => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, {}) }), foldersLoading ? (_jsx("div", { className: "flex-1 overflow-y-auto px-3 pb-3", children: _jsx(SkeletonList, { count: 6, className: "pt-1" }) })) : (_jsxs("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3", children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5", children: "All Mailboxes" }), _jsx("div", { className: "flex flex-col gap-0.5", children: AGGREGATE_FOLDER_TYPES.map((type) => {
|
|
187
|
+
const unread = aggregateUnreadCount(mailboxFolders, type);
|
|
188
|
+
return (_jsxs("a", { href: `/?aggregate=${encodeURIComponent(type)}`, className: [
|
|
189
|
+
"flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
|
|
190
|
+
aggregateFolderType === type
|
|
191
|
+
? "bg-primary/10 text-primary-dark font-semibold"
|
|
192
|
+
: "text-text hover:bg-surface-alt",
|
|
193
|
+
].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[type] }), unread > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: unread }))] }, type));
|
|
194
|
+
}) })] })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsxs("div", { children: [_jsxs("div", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate", children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }), mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: sortedFoldersOf(folders).map((folder) => (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`, className: [
|
|
195
|
+
"flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
|
|
196
|
+
folder.uid === folderUid
|
|
197
|
+
? "bg-primary/10 text-primary-dark font-semibold"
|
|
198
|
+
: "text-text hover:bg-surface-alt",
|
|
199
|
+
].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[folder.type] ?? folder.name }), folder.unreadCount > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: folder.unreadCount }))] }, folder.uid))) })] }, mailbox.uid)))] }))] }));
|
|
200
|
+
inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent() }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent() }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
139
201
|
}
|
|
140
202
|
// Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
|
|
141
203
|
// resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
|
|
@@ -143,6 +205,19 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
143
205
|
// position) would make AppShell itself remount the moment mailboxUid resolves, tearing down
|
|
144
206
|
// whatever state/effects it had already started (confirmed by direct reproduction: the
|
|
145
207
|
// impersonation banner's own internal state was lost exactly at that transition).
|
|
146
|
-
const
|
|
147
|
-
return (
|
|
208
|
+
const activeMailbox = mailboxes.find((mb) => mb.uid === activeMailboxUid);
|
|
209
|
+
return (_jsxs(KeyEnrollmentGate, { mailboxUid: activeMailboxUid, mailboxAddress: activeMailbox?.primarySmtpAddress, mailboxKeys: activeMailbox?.keys,
|
|
210
|
+
// Unlocking is only actually required to sign/encrypt a compose, read an already-encrypted
|
|
211
|
+
// message, or change encryption settings - not merely to open Mail. Those specific call sites
|
|
212
|
+
// (ComposeWindow, MessageDetailPane) request an unlock on demand via useUnlockPrompt() instead.
|
|
213
|
+
// First-time provisioning (a mailbox with no vault at all yet) still always blocks - see this
|
|
214
|
+
// prop's own doc comment on KeyEnrollmentGateProps.
|
|
215
|
+
blocking: false,
|
|
216
|
+
// A shared/delegated mailbox the caller doesn't own is never provisioned from here - its keys
|
|
217
|
+
// belong to its owner (or its admins), not to whoever happens to open it first - and neither is
|
|
218
|
+
// the impersonated user's own mailbox: an administrator must never choose its password/recovery codes.
|
|
219
|
+
canProvision: !impersonating && activeMailbox?.ownerUserUid === userUid, children: [_jsx(LocalIndexLifecycle, { mailboxUid: activeMailboxUid, mailboxKeys: activeMailbox?.keys, folders: mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? [],
|
|
220
|
+
// A full page may be truncated - pruning against it would delete indexes of accessible mailboxes
|
|
221
|
+
// beyond the limit, so it's only trusted as the complete list when it came back short.
|
|
222
|
+
accessibleMailboxUids: status === "ready" && mailboxes.length < MAILBOX_LIST_LIMIT ? mailboxes.map((mb) => mb.uid) : undefined }), _jsx(AppShell, { active: "mail", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, children: inner })] }));
|
|
148
223
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The trusted signing-key fingerprints for a received message's sender, for `evaluateMessageSecurity()`'s
|
|
7
|
+
* `pinnedSignerFingerprints`: the keys key discovery already pinned on the contacts in the reading mailbox's own
|
|
8
|
+
* contacts folder(s). Reads only stored contacts - never triggers a Discovery lookup, which must not happen on
|
|
9
|
+
* message receipt.
|
|
10
|
+
*
|
|
11
|
+
* `MessageDetailPane` renders once per opened message (and once per message in a thread), so each mailbox's
|
|
12
|
+
* contact list is fetched once and reused for `CONTACTS_CACHE_TTL_MS`; a failed load isn't cached. Any failure
|
|
13
|
+
* leaves the caller with no pins, which `evaluateMessageSecurity()` reports as an unverified signer - never as
|
|
14
|
+
* verified.
|
|
15
|
+
*
|
|
16
|
+
* The cache is dropped (`clearPinnedSignerCache()`) whenever a key session locks (subscribed on first use), on sign-out
|
|
17
|
+
* (`AppShell`), and after the reader adds, edits or deletes contacts (the contacts pages) - so a contact removed
|
|
18
|
+
* because its key was revoked doesn't keep vouching for signatures until the TTL runs out.
|
|
19
|
+
*/
|
|
20
|
+
import { PINNED_FINGERPRINT_MAX_PAGES, PINNED_FINGERPRINT_PAGE_SIZE, listContacts, pinnedSigningFingerprintsFor, signerKeyStateFor, } from "@rapidmx/react-shared/contacts/contactsApi.js";
|
|
21
|
+
import { keyPinnedSince } from "../contacts/contactKeys.js";
|
|
22
|
+
import { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
23
|
+
import { subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
24
|
+
/** How long one mailbox's loaded contact list is reused before it is fetched again. */
|
|
25
|
+
export const CONTACTS_CACHE_TTL_MS = 60000;
|
|
26
|
+
const contactsCache = new Map();
|
|
27
|
+
/** Forgets every cached contact list - on sign-out, a key-session lock, after the reader changes their contacts, and
|
|
28
|
+
* in tests. */
|
|
29
|
+
export function clearPinnedSignerCache() {
|
|
30
|
+
contactsCache.clear();
|
|
31
|
+
}
|
|
32
|
+
let lockSubscribed = false;
|
|
33
|
+
/** Subscribes (once, lazily - a module-level subscription would run in every page that merely imports this) to key
|
|
34
|
+
* session changes, clearing the cache whenever any mailbox's keys are locked. */
|
|
35
|
+
function subscribeToLocksOnce() {
|
|
36
|
+
if (lockSubscribed) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
lockSubscribed = true;
|
|
40
|
+
subscribeKeySession((event) => {
|
|
41
|
+
if (event.state === "locked") {
|
|
42
|
+
clearPinnedSignerCache();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async function loadMailboxContacts(mailboxUid) {
|
|
47
|
+
const folders = await listFolders(mailboxUid);
|
|
48
|
+
const contacts = [];
|
|
49
|
+
for (const folder of folders.filter((f) => f.type === "contacts")) {
|
|
50
|
+
for (let page = 0; page < PINNED_FINGERPRINT_MAX_PAGES; page++) {
|
|
51
|
+
const batch = await listContacts(folder.uid, { limit: PINNED_FINGERPRINT_PAGE_SIZE, page });
|
|
52
|
+
contacts.push(...batch);
|
|
53
|
+
if (batch.length < PINNED_FINGERPRINT_PAGE_SIZE) {
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return contacts;
|
|
59
|
+
}
|
|
60
|
+
/** `mailboxUid`'s contacts, from the cache when fresh. Rejects if they can't be loaded. */
|
|
61
|
+
function cachedMailboxContacts(mailboxUid) {
|
|
62
|
+
subscribeToLocksOnce();
|
|
63
|
+
let entry = contactsCache.get(mailboxUid);
|
|
64
|
+
if (!entry || Date.now() - entry.loadedAt > CONTACTS_CACHE_TTL_MS) {
|
|
65
|
+
const created = { loadedAt: Date.now(), contacts: loadMailboxContacts(mailboxUid) };
|
|
66
|
+
contactsCache.set(mailboxUid, created);
|
|
67
|
+
created.contacts.catch(() => {
|
|
68
|
+
if (contactsCache.get(mailboxUid) === created) {
|
|
69
|
+
contactsCache.delete(mailboxUid);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
entry = created;
|
|
73
|
+
}
|
|
74
|
+
return entry.contacts;
|
|
75
|
+
}
|
|
76
|
+
/** The sender `address`'s pinned signing fingerprints from `mailboxUid`'s contacts. Rejects if they can't be loaded. */
|
|
77
|
+
export async function getPinnedSignerFingerprints(mailboxUid, address) {
|
|
78
|
+
return pinnedSigningFingerprintsFor(await cachedMailboxContacts(mailboxUid), address);
|
|
79
|
+
}
|
|
80
|
+
/** The sender `address`'s signing-key state from `mailboxUid`'s (cached) contacts, for a "signing key changed" comparison
|
|
81
|
+
* or to spot a recorded conflict. Reads stored contacts only. Rejects if they can't be loaded. */
|
|
82
|
+
export async function getSignerKeyState(mailboxUid, address) {
|
|
83
|
+
const contacts = await cachedMailboxContacts(mailboxUid);
|
|
84
|
+
const state = signerKeyStateFor(contacts, address);
|
|
85
|
+
const wanted = address.trim().toLowerCase();
|
|
86
|
+
const matching = contacts.filter((contact) => contact.emails.some((email) => email.address.trim().toLowerCase() === wanted));
|
|
87
|
+
// `signerKeyStateFor()` keeps the contacts' own key objects, so the holder is found by identity.
|
|
88
|
+
const pinned = state.pinned[0];
|
|
89
|
+
const holder = matching.find((candidate) => (candidate.keys ?? []).includes(pinned));
|
|
90
|
+
const contact = holder ??
|
|
91
|
+
matching.find((candidate) => (candidate.keyConflicts ?? []).some((conflict) => conflict.useType === "sign")) ??
|
|
92
|
+
matching[0];
|
|
93
|
+
return {
|
|
94
|
+
...state,
|
|
95
|
+
...(contact ? { contactUid: contact.uid } : {}),
|
|
96
|
+
...(holder ? { pinnedSince: keyPinnedSince(holder, pinned) } : {}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The web client's side of verification seals (`@rapidmx/react-shared`'s `crypto/verificationSeal.ts`): the vault's
|
|
7
|
+
* current `masterKeyGeneration` a seal is checked and written under, and best-effort seal writes.
|
|
8
|
+
*
|
|
9
|
+
* **Vault generation.** `getVaultGeneration()` reads `KeyVault.masterKeyGeneration` once per mailbox and reuses it for
|
|
10
|
+
* `VAULT_GENERATION_TTL_MS`; a failed read isn't cached. A vault that doesn't report a non-negative integer generation
|
|
11
|
+
* (a server predating it) counts as unavailable, and callers then evaluate without seals at all. The cache is dropped
|
|
12
|
+
* whenever a key session locks (subscribed on first use), so a rekey followed by a re-unlock is picked up.
|
|
13
|
+
*
|
|
14
|
+
* **Writes.** `sendVerificationSeal()` stores `sealToWrite` with `setMessageVerificationSeal()` and never rejects. Each
|
|
15
|
+
* message is written at most once per master key generation per page session - a seal carries the time it was built,
|
|
16
|
+
* so every evaluation of the same message produces a different one, and sending each would only draw 409s. A `409`
|
|
17
|
+
* (the server kept its seal), `400`, `403` or `404` is final; any other failure (a network error) lets a later
|
|
18
|
+
* evaluation try again, never a retry loop. A stored seal is remembered (`currentVerificationSeal()`) so a message
|
|
19
|
+
* reopened before its list copy is refreshed opens that seal instead of building another.
|
|
20
|
+
*/
|
|
21
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
22
|
+
import { getKeyVault } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
23
|
+
import { subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
24
|
+
import { setMessageVerificationSeal } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
25
|
+
/** How long one mailbox's vault generation is reused before it is read again. */
|
|
26
|
+
export const VAULT_GENERATION_TTL_MS = 60000;
|
|
27
|
+
/** Statuses after which a seal write is never attempted again for that message and generation. */
|
|
28
|
+
const FINAL_WRITE_STATUSES = new Set([400, 403, 404, 409]);
|
|
29
|
+
const generationCache = new Map();
|
|
30
|
+
/** `messageUid:generation` for every write sent (or in flight) this session. */
|
|
31
|
+
const attemptedWrites = new Set();
|
|
32
|
+
/** Seals this session stored, by message uid. */
|
|
33
|
+
const storedSeals = new Map();
|
|
34
|
+
let lockSubscribed = false;
|
|
35
|
+
/** Forgets the cached vault generations and this session's seal writes - in tests. */
|
|
36
|
+
export function clearVerificationSealCache() {
|
|
37
|
+
generationCache.clear();
|
|
38
|
+
attemptedWrites.clear();
|
|
39
|
+
storedSeals.clear();
|
|
40
|
+
}
|
|
41
|
+
function subscribeToLocksOnce() {
|
|
42
|
+
if (lockSubscribed) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
lockSubscribed = true;
|
|
46
|
+
subscribeKeySession((event) => {
|
|
47
|
+
if (event.state === "locked") {
|
|
48
|
+
generationCache.clear();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** Reads `mailboxUid`'s current vault `masterKeyGeneration`, uncached: `undefined` when the vault can't be read or
|
|
53
|
+
* reports none. Never rejects. */
|
|
54
|
+
export async function readVaultGeneration(mailboxUid) {
|
|
55
|
+
try {
|
|
56
|
+
const { masterKeyGeneration } = await getKeyVault(mailboxUid);
|
|
57
|
+
return Number.isSafeInteger(masterKeyGeneration) && masterKeyGeneration >= 0 ? masterKeyGeneration : undefined;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** `mailboxUid`'s current vault `masterKeyGeneration`, or `undefined` when it can't be read. Never rejects. */
|
|
64
|
+
export function getVaultGeneration(mailboxUid) {
|
|
65
|
+
subscribeToLocksOnce();
|
|
66
|
+
const entry = generationCache.get(mailboxUid);
|
|
67
|
+
if (entry && Date.now() - entry.loadedAt <= VAULT_GENERATION_TTL_MS) {
|
|
68
|
+
return entry.generation;
|
|
69
|
+
}
|
|
70
|
+
const created = { loadedAt: Date.now(), generation: readVaultGeneration(mailboxUid) };
|
|
71
|
+
generationCache.set(mailboxUid, created);
|
|
72
|
+
void created.generation.then((generation) => {
|
|
73
|
+
// Only a readable generation is reused; an unavailable one is read again next time.
|
|
74
|
+
if (generation === undefined && generationCache.get(mailboxUid) === created) {
|
|
75
|
+
generationCache.delete(mailboxUid);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return created.generation;
|
|
79
|
+
}
|
|
80
|
+
/** The seal to check `message` against: the one this session stored for it when that is newer than the message's own
|
|
81
|
+
* copy (which may predate the write), else the message's `verificationSeal`/`verificationSealGeneration`. */
|
|
82
|
+
export function currentVerificationSeal(message) {
|
|
83
|
+
const stored = storedSeals.get(message.uid);
|
|
84
|
+
if (stored && (message.verificationSealGeneration === undefined || stored.masterKeyGeneration > message.verificationSealGeneration)) {
|
|
85
|
+
return { seal: stored.seal, sealGeneration: stored.masterKeyGeneration };
|
|
86
|
+
}
|
|
87
|
+
return { seal: message.verificationSeal, sealGeneration: message.verificationSealGeneration };
|
|
88
|
+
}
|
|
89
|
+
/** Whether `sendVerificationSeal()` would still send a seal for `messageUid` at `generation`. */
|
|
90
|
+
export function verificationSealPending(messageUid, generation) {
|
|
91
|
+
return !attemptedWrites.has(`${messageUid}:${generation}`);
|
|
92
|
+
}
|
|
93
|
+
/** Stores `sealToWrite` on `messageUid`, best effort - see this module's doc comment. Resolves `true` when a request was
|
|
94
|
+
* sent, `false` when the write was skipped as already attempted. Never rejects. */
|
|
95
|
+
export async function sendVerificationSeal(messageUid, sealToWrite) {
|
|
96
|
+
const key = `${messageUid}:${sealToWrite.masterKeyGeneration}`;
|
|
97
|
+
if (attemptedWrites.has(key)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
attemptedWrites.add(key);
|
|
101
|
+
try {
|
|
102
|
+
await setMessageVerificationSeal(messageUid, sealToWrite.seal, sealToWrite.masterKeyGeneration);
|
|
103
|
+
storedSeals.set(messageUid, sealToWrite);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
// `VerificationSealConflictError` is an `ApiRequestError` with status 409.
|
|
107
|
+
if (!(err instanceof ApiRequestError && FINAL_WRITE_STATUSES.has(err.status))) {
|
|
108
|
+
attemptedWrites.delete(key);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Which of the caller's accessible mailboxes they can actually create things in (send from, create
|
|
7
|
+
* events/contacts/to-dos in). `listMailboxes()` returns view-only shares too, and `Mailbox` itself carries
|
|
8
|
+
* no per-caller access role, so a "viewer" delegate used to be offered mailboxes every create would 403 in.
|
|
9
|
+
*
|
|
10
|
+
* The signal: an owned mailbox (or any mailbox, for a trusted caller) is always writable; for any other,
|
|
11
|
+
* `getMyMailboxAccess()`'s `canCreate` - the server's own ACL evaluation of the exact action a create needs.
|
|
12
|
+
* One request per mailbox per page load: results are cached in a module-level map, with concurrent callers
|
|
13
|
+
* sharing one in-flight request. A failure (network, an older server without that route) is "unknown" and
|
|
14
|
+
* isn't cached: the server still enforces access on the create itself, and hiding a usable mailbox over a
|
|
15
|
+
* transient error would be worse than offering one that later errors.
|
|
16
|
+
*
|
|
17
|
+
* Nothing waits on these checks: pickers list every mailbox straight away and drop view-only ones as each
|
|
18
|
+
* answer arrives.
|
|
19
|
+
*/
|
|
20
|
+
import { useEffect, useMemo, useState } from "react";
|
|
21
|
+
import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
|
|
22
|
+
const inFlight = new Map();
|
|
23
|
+
const settled = new Map();
|
|
24
|
+
/** Forgets every cached answer - for tests, and anything that changes the caller's access mid-session. */
|
|
25
|
+
export function clearMailboxWritabilityCache() {
|
|
26
|
+
inFlight.clear();
|
|
27
|
+
settled.clear();
|
|
28
|
+
}
|
|
29
|
+
function isImplicitlyWritable(mailbox, userUid, trusted) {
|
|
30
|
+
return !!trusted || (!!userUid && mailbox.ownerUserUid === userUid);
|
|
31
|
+
}
|
|
32
|
+
/** The already-known answer for `mailbox`, without making a request. */
|
|
33
|
+
export function peekMailboxWritability(mailbox, userUid, trusted) {
|
|
34
|
+
return isImplicitlyWritable(mailbox, userUid, trusted) ? true : settled.get(mailbox.uid);
|
|
35
|
+
}
|
|
36
|
+
/** Whether the caller can create items in `mailbox`. Never rejects. */
|
|
37
|
+
export function getMailboxWritability(mailbox, userUid, trusted) {
|
|
38
|
+
const known = peekMailboxWritability(mailbox, userUid, trusted);
|
|
39
|
+
if (known !== undefined) {
|
|
40
|
+
return Promise.resolve(known);
|
|
41
|
+
}
|
|
42
|
+
let request = inFlight.get(mailbox.uid);
|
|
43
|
+
if (!request) {
|
|
44
|
+
request = getMyMailboxAccess(mailbox.uid).then((access) => {
|
|
45
|
+
settled.set(mailbox.uid, access.canCreate);
|
|
46
|
+
inFlight.delete(mailbox.uid);
|
|
47
|
+
return access.canCreate;
|
|
48
|
+
}, () => {
|
|
49
|
+
// Not cached - a later check may succeed.
|
|
50
|
+
inFlight.delete(mailbox.uid);
|
|
51
|
+
return undefined;
|
|
52
|
+
});
|
|
53
|
+
inFlight.set(mailbox.uid, request);
|
|
54
|
+
}
|
|
55
|
+
return request;
|
|
56
|
+
}
|
|
57
|
+
/** Per-mailbox writability answers for `mailboxes`, filled in as each one arrives (already-known answers are
|
|
58
|
+
* present from the first render). */
|
|
59
|
+
export function useMailboxWritability(mailboxes, userUid, trusted) {
|
|
60
|
+
const [answers, setAnswers] = useState({});
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
let cancelled = false;
|
|
63
|
+
for (const mailbox of mailboxes) {
|
|
64
|
+
void getMailboxWritability(mailbox, userUid, trusted).then((writable) => {
|
|
65
|
+
if (!cancelled) {
|
|
66
|
+
setAnswers((prev) => ({ ...prev, [mailbox.uid]: writable }));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return () => {
|
|
71
|
+
cancelled = true;
|
|
72
|
+
};
|
|
73
|
+
}, [mailboxes, userUid, trusted]);
|
|
74
|
+
return useMemo(() => {
|
|
75
|
+
const merged = {};
|
|
76
|
+
for (const mailbox of mailboxes) {
|
|
77
|
+
merged[mailbox.uid] = answers[mailbox.uid] ?? peekMailboxWritability(mailbox, userUid, trusted);
|
|
78
|
+
}
|
|
79
|
+
return merged;
|
|
80
|
+
}, [answers, mailboxes, userUid, trusted]);
|
|
81
|
+
}
|
|
82
|
+
/** `mailboxes` minus any known to be view-only, always keeping `keepUid` (the picker's current/default value,
|
|
83
|
+
* so the control never shows a value missing from its own options). Lists everything until told otherwise. */
|
|
84
|
+
export function useWritableMailboxes(mailboxes, userUid, keepUid, trusted) {
|
|
85
|
+
const writability = useMailboxWritability(mailboxes, userUid, trusted);
|
|
86
|
+
return useMemo(() => mailboxes.filter((mb) => mb.uid === keepUid || writability[mb.uid] !== false), [mailboxes, writability, keepUid]);
|
|
87
|
+
}
|