@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.
Files changed (169) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/data-requests/index.tsx +480 -485
  3. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  4. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  5. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  6. package/apps/admin/index.tsx +19 -1
  7. package/apps/admin/ingest-queue/index.tsx +44 -5
  8. package/apps/admin/mailboxes/[uid].tsx +211 -184
  9. package/apps/admin/quarantine/index.tsx +46 -14
  10. package/apps/admin/transport-rules/[uid].tsx +30 -1
  11. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  12. package/apps/admin/transport-rules/index.tsx +24 -6
  13. package/apps/admin/transport-rules/new/index.tsx +30 -1
  14. package/apps/escrow/_layout.tsx +2 -3
  15. package/apps/escrow/audit-log/index.tsx +196 -168
  16. package/apps/escrow/matters/[uid].tsx +621 -521
  17. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  18. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
  19. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  20. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  21. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
  23. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
  25. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
  26. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
  27. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
  28. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
  31. package/apps/shared/components/admin/signOut.ts +79 -0
  32. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  33. package/apps/shared/components/calendar/EventModal.tsx +717 -549
  34. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  35. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  36. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  37. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  38. package/apps/shared/components/calendar/allDay.ts +124 -0
  39. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  40. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  41. package/apps/shared/components/contacts/ContactForm.tsx +377 -348
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  43. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  44. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  46. package/apps/shared/components/layout/AppShell.tsx +87 -15
  47. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  50. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  51. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
  52. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  53. package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
  54. package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
  55. package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  57. package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
  58. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  59. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  60. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  61. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  62. package/apps/shared/mail/listAllPages.ts +39 -0
  63. package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
  64. package/apps/shared/search/localIndexBuilder.ts +481 -179
  65. package/apps/shared/search/localIndexRpcClient.ts +228 -28
  66. package/apps/shared/search/localIndexSchema.ts +15 -6
  67. package/apps/shared/search/localIndexStorage.ts +98 -0
  68. package/apps/shared/search/localIndexVFS.ts +32 -1
  69. package/apps/shared/search/localIndexWorker.ts +648 -186
  70. package/apps/www/calendar/index.tsx +438 -417
  71. package/apps/www/contacts/[uid].tsx +21 -2
  72. package/apps/www/contacts/index.tsx +116 -12
  73. package/apps/www/index.tsx +1314 -1041
  74. package/apps/www/messages/[uid].tsx +18 -6
  75. package/apps/www/settings/auto-reply/index.tsx +134 -129
  76. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  77. package/apps/www/settings/encryption/index.tsx +739 -216
  78. package/apps/www/settings/filters/[uid].tsx +171 -155
  79. package/apps/www/settings/filters/new/index.tsx +8 -1
  80. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  81. package/apps/www/settings/privacy/index.tsx +494 -436
  82. package/apps/www/settings/read-receipts/index.tsx +148 -125
  83. package/apps/www/settings/sharing/index.tsx +17 -14
  84. package/apps/www/tasks/index.tsx +638 -579
  85. package/dist/apps/admin/audit-log/index.js +38 -8
  86. package/dist/apps/admin/data-requests/index.js +52 -72
  87. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  88. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  89. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  90. package/dist/apps/admin/index.js +4 -1
  91. package/dist/apps/admin/ingest-queue/index.js +24 -6
  92. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  93. package/dist/apps/admin/quarantine/index.js +20 -11
  94. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  95. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  96. package/dist/apps/admin/transport-rules/index.js +22 -6
  97. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  98. package/dist/apps/escrow/_layout.js +3 -3
  99. package/dist/apps/escrow/audit-log/index.js +26 -1
  100. package/dist/apps/escrow/matters/[uid].js +77 -42
  101. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  102. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
  103. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  104. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  105. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  106. package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
  107. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
  108. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
  109. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
  110. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
  111. package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
  112. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
  113. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
  114. package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
  115. package/dist/apps/shared/components/admin/signOut.js +75 -0
  116. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  117. package/dist/apps/shared/components/calendar/EventModal.js +142 -21
  118. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  119. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  120. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  121. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  122. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  123. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  124. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  125. package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
  126. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  127. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  128. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  129. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  130. package/dist/apps/shared/components/layout/AppShell.js +83 -14
  131. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  132. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
  133. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  134. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  135. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
  136. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  137. package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
  138. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  139. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
  140. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  141. package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
  142. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  143. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  144. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  145. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  146. package/dist/apps/shared/mail/listAllPages.js +25 -0
  147. package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
  148. package/dist/apps/shared/search/localIndexBuilder.js +292 -37
  149. package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
  150. package/dist/apps/shared/search/localIndexSchema.js +12 -6
  151. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  152. package/dist/apps/shared/search/localIndexVFS.js +68 -40
  153. package/dist/apps/shared/search/localIndexWorker.js +519 -173
  154. package/dist/apps/www/calendar/index.js +28 -10
  155. package/dist/apps/www/contacts/[uid].js +13 -2
  156. package/dist/apps/www/contacts/index.js +80 -10
  157. package/dist/apps/www/index.js +339 -108
  158. package/dist/apps/www/messages/[uid].js +18 -6
  159. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  160. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  161. package/dist/apps/www/settings/encryption/index.js +514 -97
  162. package/dist/apps/www/settings/filters/[uid].js +22 -7
  163. package/dist/apps/www/settings/filters/new/index.js +8 -2
  164. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  165. package/dist/apps/www/settings/privacy/index.js +57 -22
  166. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  167. package/dist/apps/www/settings/sharing/index.js +7 -11
  168. package/dist/apps/www/tasks/index.js +50 -10
  169. package/package.json +2 -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
+ }
@@ -57,6 +57,8 @@ function sortedFoldersOf(folders) {
57
57
  function aggregateUnreadCount(mailboxFolders, type) {
58
58
  return mailboxFolders.reduce((total, mf) => total + (mf.folders.find((f) => f.type === type)?.unreadCount ?? 0), 0);
59
59
  }
60
+ /** Page size of the one `listMailboxes()` call this shell makes. */
61
+ export const MAILBOX_LIST_LIMIT = 100;
60
62
  /**
61
63
  * A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
62
64
  * `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
@@ -108,7 +110,7 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
108
110
  if (!userUid) {
109
111
  return;
110
112
  }
111
- listMailboxes({ limit: 100 })
113
+ listMailboxes({ limit: MAILBOX_LIST_LIMIT })
112
114
  .then((result) => {
113
115
  setMailboxes(result);
114
116
  setStatus("ready");
@@ -138,7 +140,7 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
138
140
  return;
139
141
  }
140
142
  setFoldersLoading(true);
141
- Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
143
+ void Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
142
144
  .then((result) => ({ mailbox, folders: result.filter((f) => MAIL_FOLDER_TYPES.has(f.type)) }))
143
145
  .catch((err) => ({
144
146
  mailbox,
@@ -210,5 +212,12 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
210
212
  // (ComposeWindow, MessageDetailPane) request an unlock on demand via useUnlockPrompt() instead.
211
213
  // First-time provisioning (a mailbox with no vault at all yet) still always blocks - see this
212
214
  // prop's own doc comment on KeyEnrollmentGateProps.
213
- blocking: false, children: [_jsx(LocalIndexLifecycle, { mailboxUid: activeMailboxUid, mailboxKeys: activeMailbox?.keys, folders: mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? [] }), _jsx(AppShell, { active: "mail", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, children: inner })] }));
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 })] }));
214
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
+ }
@@ -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 existing = conditions[key] ?? [];
41
- patch({ conditions: { ...conditions, [key]: existing.filter((e) => e !== entry) } });
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
- patch({ conditions: { ...conditions, [key]: !conditions[key] } });
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 }), onKeyDown: (e) => {
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);
@@ -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
+ }