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