@rapidmx/web-client 0.4.0 → 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/data-requests/index.tsx +480 -485
- package/apps/admin/distribution-lists/[uid].tsx +19 -1
- package/apps/admin/escrow-scopes/[uid].tsx +348 -237
- package/apps/admin/escrow-scopes/new/index.tsx +8 -2
- package/apps/admin/index.tsx +19 -1
- package/apps/admin/ingest-queue/index.tsx +44 -5
- package/apps/admin/mailboxes/[uid].tsx +211 -184
- package/apps/admin/quarantine/index.tsx +46 -14
- 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 +3 -1
- 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 +15 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
- package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
- package/apps/shared/components/admin/signOut.ts +79 -0
- package/apps/shared/components/admin/usePagedList.tsx +129 -0
- package/apps/shared/components/calendar/EventModal.tsx +717 -549
- 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 +1 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
- package/apps/shared/components/contacts/ContactForm.tsx +377 -348
- 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 +87 -15
- package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
- 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 -128
- package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
- package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
- package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
- 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/mail/listAllPages.ts +39 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
- package/apps/shared/search/localIndexBuilder.ts +481 -179
- package/apps/shared/search/localIndexRpcClient.ts +228 -28
- package/apps/shared/search/localIndexSchema.ts +15 -6
- package/apps/shared/search/localIndexStorage.ts +98 -0
- package/apps/shared/search/localIndexVFS.ts +32 -1
- package/apps/shared/search/localIndexWorker.ts +648 -186
- package/apps/www/calendar/index.tsx +438 -417
- package/apps/www/contacts/[uid].tsx +21 -2
- package/apps/www/contacts/index.tsx +116 -12
- package/apps/www/index.tsx +1314 -1041
- package/apps/www/messages/[uid].tsx +18 -6
- 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 +739 -216
- 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 +17 -14
- package/apps/www/tasks/index.tsx +638 -579
- package/dist/apps/admin/audit-log/index.js +38 -8
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- 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 +4 -1
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/quarantine/index.js +20 -11
- 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 +3 -1
- 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 +15 -4
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
- 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/EventModal.js +142 -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 +1 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
- 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 +83 -14
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
- 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 +73 -14
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
- 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/mail/listAllPages.js +25 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
- package/dist/apps/shared/search/localIndexBuilder.js +292 -37
- package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
- package/dist/apps/shared/search/localIndexSchema.js +12 -6
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.js +68 -40
- package/dist/apps/shared/search/localIndexWorker.js +519 -173
- package/dist/apps/www/calendar/index.js +28 -10
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.js +80 -10
- package/dist/apps/www/index.js +339 -108
- package/dist/apps/www/messages/[uid].js +18 -6
- 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 +514 -97
- 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 +7 -11
- package/dist/apps/www/tasks/index.js +50 -10
- package/package.json +2 -2
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** Saves a compose window's unsaved edits (or waits for a save already on the wire), resolving to `false` when
|
|
7
|
+
* something the window holds is still unsaved afterwards (any other value counts as saved). Never rejects. */
|
|
8
|
+
export type ComposeFlush = () => Promise<unknown>;
|
|
9
|
+
|
|
10
|
+
const flushers = new Set<ComposeFlush>();
|
|
11
|
+
let signingOut = false;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Marks the app as signing out (explicitly, or forced by a sign-out in another tab), so compose windows stop
|
|
15
|
+
* asking the browser to confirm leaving the page - the "Leave site?" prompt would otherwise let the user cancel
|
|
16
|
+
* the sign-out's own navigation and stay on a page whose session is being ended. Call it before flushing drafts
|
|
17
|
+
* and navigating. `clearSigningOut()` undoes it (a sign-out that didn't leave the page after all).
|
|
18
|
+
*/
|
|
19
|
+
export function markSigningOut(): void {
|
|
20
|
+
signingOut = true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Undoes `markSigningOut()`. */
|
|
24
|
+
export function clearSigningOut(): void {
|
|
25
|
+
signingOut = false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether `markSigningOut()` has been called (and not cleared). */
|
|
29
|
+
export function isSigningOut(): boolean {
|
|
30
|
+
return signingOut;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Registers an open compose window's draft flush, so leaving the app on purpose (Sign Out) can save the
|
|
35
|
+
* last edits still waiting on the autosave debounce before the session ends. Returns the unregister
|
|
36
|
+
* function. Module-level rather than React context: `AppShell` (the sign-out handler) renders the
|
|
37
|
+
* `ComposeProvider` itself, so it can't read that provider's context.
|
|
38
|
+
*/
|
|
39
|
+
export function registerComposeFlush(flush: ComposeFlush): () => void {
|
|
40
|
+
flushers.add(flush);
|
|
41
|
+
return () => {
|
|
42
|
+
flushers.delete(flush);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Runs every registered flush and waits for all of them, but no longer than `timeoutMs`. Resolves to `true`
|
|
48
|
+
* only when every window reported its content saved in time (a window whose save failed also shows the user
|
|
49
|
+
* why, with Discard / Keep editing). Never rejects.
|
|
50
|
+
*/
|
|
51
|
+
export async function flushComposeDrafts(timeoutMs: number): Promise<boolean> {
|
|
52
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
53
|
+
const timeout = new Promise<boolean>((resolve) => {
|
|
54
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
55
|
+
});
|
|
56
|
+
const all = Promise.all([...flushers].map((flush) => flush().catch(() => false))).then((results) => results.every((result) => result !== false));
|
|
57
|
+
const saved = await Promise.race([all, timeout]);
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
return saved;
|
|
60
|
+
}
|
|
@@ -99,6 +99,9 @@ function aggregateUnreadCount(mailboxFolders: MailboxFolders[], type: AggregateF
|
|
|
99
99
|
|
|
100
100
|
type Status = "checking" | "error" | "ready";
|
|
101
101
|
|
|
102
|
+
/** Page size of the one `listMailboxes()` call this shell makes. */
|
|
103
|
+
export const MAILBOX_LIST_LIMIT = 100;
|
|
104
|
+
|
|
102
105
|
/**
|
|
103
106
|
* A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
|
|
104
107
|
* `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
|
|
@@ -168,7 +171,7 @@ export default function MailShell({
|
|
|
168
171
|
if (!userUid) {
|
|
169
172
|
return;
|
|
170
173
|
}
|
|
171
|
-
listMailboxes({ limit:
|
|
174
|
+
listMailboxes({ limit: MAILBOX_LIST_LIMIT })
|
|
172
175
|
.then((result) => {
|
|
173
176
|
setMailboxes(result);
|
|
174
177
|
setStatus("ready");
|
|
@@ -201,7 +204,7 @@ export default function MailShell({
|
|
|
201
204
|
return;
|
|
202
205
|
}
|
|
203
206
|
setFoldersLoading(true);
|
|
204
|
-
Promise.all(
|
|
207
|
+
void Promise.all(
|
|
205
208
|
mailboxes.map((mailbox) =>
|
|
206
209
|
listFolders(mailbox.uid)
|
|
207
210
|
.then((result): MailboxFolders => ({ mailbox, folders: result.filter((f) => MAIL_FOLDER_TYPES.has(f.type)) }))
|
|
@@ -387,11 +390,18 @@ export default function MailShell({
|
|
|
387
390
|
// First-time provisioning (a mailbox with no vault at all yet) still always blocks - see this
|
|
388
391
|
// prop's own doc comment on KeyEnrollmentGateProps.
|
|
389
392
|
blocking={false}
|
|
393
|
+
// A shared/delegated mailbox the caller doesn't own is never provisioned from here - its keys
|
|
394
|
+
// belong to its owner (or its admins), not to whoever happens to open it first - and neither is
|
|
395
|
+
// the impersonated user's own mailbox: an administrator must never choose its password/recovery codes.
|
|
396
|
+
canProvision={!impersonating && activeMailbox?.ownerUserUid === userUid}
|
|
390
397
|
>
|
|
391
398
|
<LocalIndexLifecycle
|
|
392
399
|
mailboxUid={activeMailboxUid}
|
|
393
400
|
mailboxKeys={activeMailbox?.keys}
|
|
394
401
|
folders={mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? []}
|
|
402
|
+
// A full page may be truncated - pruning against it would delete indexes of accessible mailboxes
|
|
403
|
+
// beyond the limit, so it's only trusted as the complete list when it came back short.
|
|
404
|
+
accessibleMailboxUids={status === "ready" && mailboxes.length < MAILBOX_LIST_LIMIT ? mailboxes.map((mb) => mb.uid) : undefined}
|
|
395
405
|
/>
|
|
396
406
|
<AppShell
|
|
397
407
|
active="mail"
|
|
@@ -0,0 +1,124 @@
|
|
|
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 {
|
|
21
|
+
Contact,
|
|
22
|
+
PINNED_FINGERPRINT_MAX_PAGES,
|
|
23
|
+
PINNED_FINGERPRINT_PAGE_SIZE,
|
|
24
|
+
SignerKeyState,
|
|
25
|
+
listContacts,
|
|
26
|
+
pinnedSigningFingerprintsFor,
|
|
27
|
+
signerKeyStateFor,
|
|
28
|
+
} from "@rapidmx/react-shared/contacts/contactsApi.js";
|
|
29
|
+
import { keyPinnedSince } from "../contacts/contactKeys.js";
|
|
30
|
+
import { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
31
|
+
import { subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
32
|
+
|
|
33
|
+
/** How long one mailbox's loaded contact list is reused before it is fetched again. */
|
|
34
|
+
export const CONTACTS_CACHE_TTL_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
const contactsCache = new Map<string, { loadedAt: number; contacts: Promise<Contact[]> }>();
|
|
37
|
+
|
|
38
|
+
/** Forgets every cached contact list - on sign-out, a key-session lock, after the reader changes their contacts, and
|
|
39
|
+
* in tests. */
|
|
40
|
+
export function clearPinnedSignerCache(): void {
|
|
41
|
+
contactsCache.clear();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let lockSubscribed = false;
|
|
45
|
+
|
|
46
|
+
/** Subscribes (once, lazily - a module-level subscription would run in every page that merely imports this) to key
|
|
47
|
+
* session changes, clearing the cache whenever any mailbox's keys are locked. */
|
|
48
|
+
function subscribeToLocksOnce(): void {
|
|
49
|
+
if (lockSubscribed) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
lockSubscribed = true;
|
|
53
|
+
subscribeKeySession((event) => {
|
|
54
|
+
if (event.state === "locked") {
|
|
55
|
+
clearPinnedSignerCache();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function loadMailboxContacts(mailboxUid: string): Promise<Contact[]> {
|
|
61
|
+
const folders = await listFolders(mailboxUid);
|
|
62
|
+
const contacts: Contact[] = [];
|
|
63
|
+
for (const folder of folders.filter((f) => f.type === "contacts")) {
|
|
64
|
+
for (let page = 0; page < PINNED_FINGERPRINT_MAX_PAGES; page++) {
|
|
65
|
+
const batch = await listContacts(folder.uid, { limit: PINNED_FINGERPRINT_PAGE_SIZE, page });
|
|
66
|
+
contacts.push(...batch);
|
|
67
|
+
if (batch.length < PINNED_FINGERPRINT_PAGE_SIZE) {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return contacts;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `mailboxUid`'s contacts, from the cache when fresh. Rejects if they can't be loaded. */
|
|
76
|
+
function cachedMailboxContacts(mailboxUid: string): Promise<Contact[]> {
|
|
77
|
+
subscribeToLocksOnce();
|
|
78
|
+
let entry = contactsCache.get(mailboxUid);
|
|
79
|
+
if (!entry || Date.now() - entry.loadedAt > CONTACTS_CACHE_TTL_MS) {
|
|
80
|
+
const created = { loadedAt: Date.now(), contacts: loadMailboxContacts(mailboxUid) };
|
|
81
|
+
contactsCache.set(mailboxUid, created);
|
|
82
|
+
created.contacts.catch(() => {
|
|
83
|
+
if (contactsCache.get(mailboxUid) === created) {
|
|
84
|
+
contactsCache.delete(mailboxUid);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
entry = created;
|
|
88
|
+
}
|
|
89
|
+
return entry.contacts;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The sender `address`'s pinned signing fingerprints from `mailboxUid`'s contacts. Rejects if they can't be loaded. */
|
|
93
|
+
export async function getPinnedSignerFingerprints(mailboxUid: string, address: string): Promise<string[]> {
|
|
94
|
+
return pinnedSigningFingerprintsFor(await cachedMailboxContacts(mailboxUid), address);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A sender's signing-key state (`signerKeyStateFor()`) plus what a key-changed notice needs to show and link. */
|
|
98
|
+
export interface SenderKeyState extends SignerKeyState {
|
|
99
|
+
/** The contact holding the first pinned signing key, else the one holding the conflict, else the first match. */
|
|
100
|
+
contactUid?: string;
|
|
101
|
+
/** When the first pinned signing key started being trusted - see `keyPinnedSince()`. */
|
|
102
|
+
pinnedSince?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The sender `address`'s signing-key state from `mailboxUid`'s (cached) contacts, for a "signing key changed" comparison
|
|
106
|
+
* or to spot a recorded conflict. Reads stored contacts only. Rejects if they can't be loaded. */
|
|
107
|
+
export async function getSignerKeyState(mailboxUid: string, address: string): Promise<SenderKeyState> {
|
|
108
|
+
const contacts = await cachedMailboxContacts(mailboxUid);
|
|
109
|
+
const state = signerKeyStateFor(contacts, address);
|
|
110
|
+
const wanted = address.trim().toLowerCase();
|
|
111
|
+
const matching = contacts.filter((contact) => contact.emails.some((email) => email.address.trim().toLowerCase() === wanted));
|
|
112
|
+
// `signerKeyStateFor()` keeps the contacts' own key objects, so the holder is found by identity.
|
|
113
|
+
const pinned = state.pinned[0];
|
|
114
|
+
const holder = matching.find((candidate) => (candidate.keys ?? []).includes(pinned));
|
|
115
|
+
const contact =
|
|
116
|
+
holder ??
|
|
117
|
+
matching.find((candidate) => (candidate.keyConflicts ?? []).some((conflict) => conflict.useType === "sign")) ??
|
|
118
|
+
matching[0];
|
|
119
|
+
return {
|
|
120
|
+
...state,
|
|
121
|
+
...(contact ? { contactUid: contact.uid } : {}),
|
|
122
|
+
...(holder ? { pinnedSince: keyPinnedSince(holder, pinned) } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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 { type Message, setMessageVerificationSeal } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
25
|
+
import type { SealToWrite } from "@rapidmx/react-shared/crypto/messageSecurity.js";
|
|
26
|
+
|
|
27
|
+
/** How long one mailbox's vault generation is reused before it is read again. */
|
|
28
|
+
export const VAULT_GENERATION_TTL_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
/** Statuses after which a seal write is never attempted again for that message and generation. */
|
|
31
|
+
const FINAL_WRITE_STATUSES = new Set([400, 403, 404, 409]);
|
|
32
|
+
|
|
33
|
+
const generationCache = new Map<string, { loadedAt: number; generation: Promise<number | undefined> }>();
|
|
34
|
+
/** `messageUid:generation` for every write sent (or in flight) this session. */
|
|
35
|
+
const attemptedWrites = new Set<string>();
|
|
36
|
+
/** Seals this session stored, by message uid. */
|
|
37
|
+
const storedSeals = new Map<string, SealToWrite>();
|
|
38
|
+
|
|
39
|
+
let lockSubscribed = false;
|
|
40
|
+
|
|
41
|
+
/** Forgets the cached vault generations and this session's seal writes - in tests. */
|
|
42
|
+
export function clearVerificationSealCache(): void {
|
|
43
|
+
generationCache.clear();
|
|
44
|
+
attemptedWrites.clear();
|
|
45
|
+
storedSeals.clear();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function subscribeToLocksOnce(): void {
|
|
49
|
+
if (lockSubscribed) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
lockSubscribed = true;
|
|
53
|
+
subscribeKeySession((event) => {
|
|
54
|
+
if (event.state === "locked") {
|
|
55
|
+
generationCache.clear();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Reads `mailboxUid`'s current vault `masterKeyGeneration`, uncached: `undefined` when the vault can't be read or
|
|
61
|
+
* reports none. Never rejects. */
|
|
62
|
+
export async function readVaultGeneration(mailboxUid: string): Promise<number | undefined> {
|
|
63
|
+
try {
|
|
64
|
+
const { masterKeyGeneration } = await getKeyVault(mailboxUid);
|
|
65
|
+
return Number.isSafeInteger(masterKeyGeneration) && masterKeyGeneration! >= 0 ? masterKeyGeneration : undefined;
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `mailboxUid`'s current vault `masterKeyGeneration`, or `undefined` when it can't be read. Never rejects. */
|
|
72
|
+
export function getVaultGeneration(mailboxUid: string): Promise<number | undefined> {
|
|
73
|
+
subscribeToLocksOnce();
|
|
74
|
+
const entry = generationCache.get(mailboxUid);
|
|
75
|
+
if (entry && Date.now() - entry.loadedAt <= VAULT_GENERATION_TTL_MS) {
|
|
76
|
+
return entry.generation;
|
|
77
|
+
}
|
|
78
|
+
const created = { loadedAt: Date.now(), generation: readVaultGeneration(mailboxUid) };
|
|
79
|
+
generationCache.set(mailboxUid, created);
|
|
80
|
+
void created.generation.then((generation) => {
|
|
81
|
+
// Only a readable generation is reused; an unavailable one is read again next time.
|
|
82
|
+
if (generation === undefined && generationCache.get(mailboxUid) === created) {
|
|
83
|
+
generationCache.delete(mailboxUid);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return created.generation;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The seal to check `message` against: the one this session stored for it when that is newer than the message's own
|
|
90
|
+
* copy (which may predate the write), else the message's `verificationSeal`/`verificationSealGeneration`. */
|
|
91
|
+
export function currentVerificationSeal(message: Pick<Message, "uid" | "verificationSeal" | "verificationSealGeneration">): {
|
|
92
|
+
seal?: string;
|
|
93
|
+
sealGeneration?: number;
|
|
94
|
+
} {
|
|
95
|
+
const stored = storedSeals.get(message.uid);
|
|
96
|
+
if (stored && (message.verificationSealGeneration === undefined || stored.masterKeyGeneration > message.verificationSealGeneration)) {
|
|
97
|
+
return { seal: stored.seal, sealGeneration: stored.masterKeyGeneration };
|
|
98
|
+
}
|
|
99
|
+
return { seal: message.verificationSeal, sealGeneration: message.verificationSealGeneration };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Whether `sendVerificationSeal()` would still send a seal for `messageUid` at `generation`. */
|
|
103
|
+
export function verificationSealPending(messageUid: string, generation: number): boolean {
|
|
104
|
+
return !attemptedWrites.has(`${messageUid}:${generation}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Stores `sealToWrite` on `messageUid`, best effort - see this module's doc comment. Resolves `true` when a request was
|
|
108
|
+
* sent, `false` when the write was skipped as already attempted. Never rejects. */
|
|
109
|
+
export async function sendVerificationSeal(messageUid: string, sealToWrite: SealToWrite): Promise<boolean> {
|
|
110
|
+
const key = `${messageUid}:${sealToWrite.masterKeyGeneration}`;
|
|
111
|
+
if (attemptedWrites.has(key)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
attemptedWrites.add(key);
|
|
115
|
+
try {
|
|
116
|
+
await setMessageVerificationSeal(messageUid, sealToWrite.seal, sealToWrite.masterKeyGeneration);
|
|
117
|
+
storedSeals.set(messageUid, sealToWrite);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// `VerificationSealConflictError` is an `ApiRequestError` with status 409.
|
|
120
|
+
if (!(err instanceof ApiRequestError && FINAL_WRITE_STATUSES.has(err.status))) {
|
|
121
|
+
attemptedWrites.delete(key);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
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 type { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
22
|
+
import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
|
|
23
|
+
|
|
24
|
+
/** `true` writable, `false` known view-only, `undefined` couldn't tell (treated as writable). */
|
|
25
|
+
export type MailboxWritability = boolean | undefined;
|
|
26
|
+
|
|
27
|
+
const inFlight = new Map<string, Promise<MailboxWritability>>();
|
|
28
|
+
const settled = new Map<string, boolean>();
|
|
29
|
+
|
|
30
|
+
/** Forgets every cached answer - for tests, and anything that changes the caller's access mid-session. */
|
|
31
|
+
export function clearMailboxWritabilityCache(): void {
|
|
32
|
+
inFlight.clear();
|
|
33
|
+
settled.clear();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isImplicitlyWritable(mailbox: Mailbox, userUid: string | undefined, trusted: boolean | undefined): boolean {
|
|
37
|
+
return !!trusted || (!!userUid && mailbox.ownerUserUid === userUid);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The already-known answer for `mailbox`, without making a request. */
|
|
41
|
+
export function peekMailboxWritability(mailbox: Mailbox, userUid: string | undefined, trusted?: boolean): MailboxWritability {
|
|
42
|
+
return isImplicitlyWritable(mailbox, userUid, trusted) ? true : settled.get(mailbox.uid);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether the caller can create items in `mailbox`. Never rejects. */
|
|
46
|
+
export function getMailboxWritability(mailbox: Mailbox, userUid: string | undefined, trusted?: boolean): Promise<MailboxWritability> {
|
|
47
|
+
const known = peekMailboxWritability(mailbox, userUid, trusted);
|
|
48
|
+
if (known !== undefined) {
|
|
49
|
+
return Promise.resolve(known);
|
|
50
|
+
}
|
|
51
|
+
let request = inFlight.get(mailbox.uid);
|
|
52
|
+
if (!request) {
|
|
53
|
+
request = getMyMailboxAccess(mailbox.uid).then(
|
|
54
|
+
(access) => {
|
|
55
|
+
settled.set(mailbox.uid, access.canCreate);
|
|
56
|
+
inFlight.delete(mailbox.uid);
|
|
57
|
+
return access.canCreate;
|
|
58
|
+
},
|
|
59
|
+
() => {
|
|
60
|
+
// Not cached - a later check may succeed.
|
|
61
|
+
inFlight.delete(mailbox.uid);
|
|
62
|
+
return undefined;
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
inFlight.set(mailbox.uid, request);
|
|
66
|
+
}
|
|
67
|
+
return request;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Per-mailbox writability answers for `mailboxes`, filled in as each one arrives (already-known answers are
|
|
71
|
+
* present from the first render). */
|
|
72
|
+
export function useMailboxWritability(mailboxes: Mailbox[], userUid: string | undefined, trusted?: boolean): Record<string, MailboxWritability> {
|
|
73
|
+
const [answers, setAnswers] = useState<Record<string, MailboxWritability>>({});
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
let cancelled = false;
|
|
76
|
+
for (const mailbox of mailboxes) {
|
|
77
|
+
void getMailboxWritability(mailbox, userUid, trusted).then((writable) => {
|
|
78
|
+
if (!cancelled) {
|
|
79
|
+
setAnswers((prev) => ({ ...prev, [mailbox.uid]: writable }));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return () => {
|
|
84
|
+
cancelled = true;
|
|
85
|
+
};
|
|
86
|
+
}, [mailboxes, userUid, trusted]);
|
|
87
|
+
return useMemo(() => {
|
|
88
|
+
const merged: Record<string, MailboxWritability> = {};
|
|
89
|
+
for (const mailbox of mailboxes) {
|
|
90
|
+
merged[mailbox.uid] = answers[mailbox.uid] ?? peekMailboxWritability(mailbox, userUid, trusted);
|
|
91
|
+
}
|
|
92
|
+
return merged;
|
|
93
|
+
}, [answers, mailboxes, userUid, trusted]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `mailboxes` minus any known to be view-only, always keeping `keepUid` (the picker's current/default value,
|
|
97
|
+
* so the control never shows a value missing from its own options). Lists everything until told otherwise. */
|
|
98
|
+
export function useWritableMailboxes(mailboxes: Mailbox[], userUid: string | undefined, keepUid?: string, trusted?: boolean): Mailbox[] {
|
|
99
|
+
const writability = useMailboxWritability(mailboxes, userUid, trusted);
|
|
100
|
+
return useMemo(() => mailboxes.filter((mb) => mb.uid === keepUid || writability[mb.uid] !== false), [mailboxes, writability, keepUid]);
|
|
101
|
+
}
|