@rapidmx/web-client 0.15.1 → 0.16.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 (189) hide show
  1. package/README.md +373 -373
  2. package/apps/admin/branding/index.tsx +39 -39
  3. package/apps/admin/data-requests/index.tsx +482 -480
  4. package/apps/admin/domains/[uid].tsx +164 -164
  5. package/apps/admin/escrow-scopes/[uid].tsx +348 -348
  6. package/apps/admin/index.tsx +127 -124
  7. package/apps/admin/mailboxes/[uid].tsx +269 -224
  8. package/apps/admin/mailboxes/new/index.tsx +28 -28
  9. package/apps/admin/plugins/index.tsx +15 -15
  10. package/apps/admin/retention-policy/index.tsx +39 -39
  11. package/apps/admin/signing-certificates/index.tsx +343 -343
  12. package/apps/escrow/audit-log/index.tsx +196 -196
  13. package/apps/escrow/matters/[uid].tsx +621 -621
  14. package/apps/shared/auth/adminAccess.ts +99 -99
  15. package/apps/shared/components/admin/layout/AdminShell.tsx +378 -378
  16. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
  17. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  18. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
  19. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  20. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  21. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
  22. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  23. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
  24. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  25. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  26. package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
  27. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  28. package/apps/shared/components/calendar/EventEditor.tsx +5 -10
  29. package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
  30. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  31. package/apps/shared/components/calendar/GuestInput.tsx +100 -0
  32. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  33. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  34. package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
  35. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  36. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  37. package/apps/shared/components/calendar/allDay.ts +124 -124
  38. package/apps/shared/components/calendar/eventForm.ts +2 -2
  39. package/apps/shared/components/calendar/eventFormat.ts +49 -22
  40. package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
  41. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
  43. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
  44. package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  46. package/apps/shared/components/layout/AppShell.tsx +480 -480
  47. package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  50. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  51. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  52. package/apps/shared/components/layout/UserMenu.tsx +439 -424
  53. package/apps/shared/components/mail/ConversationList.tsx +323 -323
  54. package/apps/shared/components/mail/ConversationThreadPane.tsx +612 -491
  55. package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
  56. package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1559
  57. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  58. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  59. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
  60. package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
  61. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  62. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  63. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  64. package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
  65. package/apps/shared/components/mail/layout/MailShell.tsx +137 -113
  66. package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
  67. package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
  68. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  69. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  70. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  71. package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -254
  72. package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
  73. package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
  74. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  75. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  76. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  77. package/apps/shared/keyboard/dispatch.ts +124 -124
  78. package/apps/shared/keyboard/format.ts +89 -89
  79. package/apps/shared/keyboard/keymap.ts +114 -114
  80. package/apps/shared/keyboard/registry.ts +65 -65
  81. package/apps/shared/keyboard/targets.ts +79 -79
  82. package/apps/shared/mail/folderCounts.ts +17 -0
  83. package/apps/shared/mail/folderTree.ts +143 -143
  84. package/apps/shared/mail/listAllPages.ts +39 -39
  85. package/apps/shared/mail/newMailNotifications.ts +171 -171
  86. package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
  87. package/apps/shared/mail/outbox/sendJob.ts +19 -1
  88. package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
  89. package/apps/shared/mail/primaryMailbox.ts +71 -0
  90. package/apps/shared/mail/useCollapsedSections.ts +146 -0
  91. package/apps/shared/mail/useMailConnection.ts +205 -203
  92. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  93. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  94. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  95. package/apps/shared/navigation/AppRouter.tsx +300 -300
  96. package/apps/shared/navigation/routerContext.tsx +83 -83
  97. package/apps/shared/notifications/store.ts +560 -560
  98. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  99. package/apps/shared/search/crossMailboxSearch.ts +169 -0
  100. package/apps/shared/search/localIndexBuilder.ts +481 -481
  101. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  102. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  103. package/apps/shared/signing/enrollmentView.ts +251 -251
  104. package/apps/shared/signing/useNow.ts +19 -19
  105. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  106. package/apps/shared/styles/app.css +396 -386
  107. package/apps/www/calendar/index.tsx +578 -571
  108. package/apps/www/contacts/[uid].tsx +109 -109
  109. package/apps/www/contacts/index.tsx +6 -0
  110. package/apps/www/index.tsx +2847 -2479
  111. package/apps/www/messages/[uid].tsx +135 -135
  112. package/apps/www/settings/auto-reply/index.tsx +133 -133
  113. package/apps/www/settings/encryption/index.tsx +1287 -1287
  114. package/apps/www/settings/filters/[uid].tsx +176 -176
  115. package/apps/www/settings/filters/index.tsx +102 -102
  116. package/apps/www/settings/filters/new/index.tsx +140 -140
  117. package/apps/www/settings/labels/index.tsx +204 -204
  118. package/apps/www/settings/privacy/index.tsx +492 -492
  119. package/apps/www/settings/profile/index.tsx +248 -248
  120. package/apps/www/settings/read-receipts/index.tsx +147 -147
  121. package/apps/www/settings/sharing/index.tsx +256 -256
  122. package/apps/www/settings/signatures/[uid].tsx +172 -172
  123. package/apps/www/settings/signatures/index.tsx +88 -88
  124. package/apps/www/settings/signatures/new/index.tsx +135 -135
  125. package/apps/www/tasks/index.tsx +649 -649
  126. package/dist/apps/admin/data-requests/index.js +1 -1
  127. package/dist/apps/admin/index.js +2 -1
  128. package/dist/apps/admin/mailboxes/[uid].js +12 -2
  129. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
  130. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
  131. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
  132. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
  133. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
  134. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
  136. package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
  137. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
  138. package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
  139. package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
  140. package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
  141. package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
  142. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
  143. package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
  144. package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
  145. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
  146. package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
  147. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  148. package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
  149. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
  150. package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
  151. package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
  152. package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
  153. package/dist/apps/shared/components/layout/UserMenu.js +6 -5
  154. package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
  155. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
  156. package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
  157. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
  158. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
  159. package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
  160. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
  161. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
  162. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  163. package/dist/apps/shared/components/mail/layout/MailShell.js +64 -45
  164. package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
  165. package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
  166. package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
  167. package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
  168. package/dist/apps/shared/components/settings/layout/SettingsShell.js +6 -5
  169. package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
  170. package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
  171. package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
  172. package/dist/apps/shared/mail/folderCounts.js +12 -0
  173. package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
  174. package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
  175. package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
  176. package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
  177. package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
  178. package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
  179. package/dist/apps/shared/mail/primaryMailbox.js +61 -0
  180. package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
  181. package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
  182. package/dist/apps/shared/mail/useMailConnection.js +3 -1
  183. package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
  184. package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
  185. package/dist/apps/shared/styles/app.css +396 -386
  186. package/dist/apps/www/calendar/index.js +3 -2
  187. package/dist/apps/www/contacts/index.js +3 -1
  188. package/dist/apps/www/index.js +426 -189
  189. package/package.json +2 -2
@@ -1,311 +1,311 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { ReactNode, useState } from "react";
6
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
7
-
8
- const INPUT_CLASS =
9
- "flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
10
-
11
- /** One condition field this scope's `Conditions` shape supports — `"list"` renders an add/remove chip
12
- * editor for a `string[]` field, `"boolean"` a plain checkbox, `"select"` a fixed-choice dropdown for a
13
- * field with a small closed set of string values (e.g. `MailFilterConditions.importance`). */
14
- export interface ConditionFieldDef {
15
- key: string;
16
- label: string;
17
- kind: "list" | "boolean" | "select";
18
- placeholder?: string;
19
- /** Required when `kind` is `"select"` — the fixed set of values this field may hold, plus an
20
- * implicit "Any" option that clears the field entirely. */
21
- options?: { value: string; label: string }[];
22
- }
23
-
24
- /** One action type this scope's `Action` union supports — the per-scope registry the shared condition
25
- * editor/chrome below is deliberately kept out of (transport-rule and mail-filter actions are
26
- * different enough vocabularies that one component special-casing both would be worse than this). */
27
- export interface ActionTypeDef<A> {
28
- value: string;
29
- label: string;
30
- /** Builds a fresh default action of this type when picked from "Add action". */
31
- createDefault: () => A;
32
- /** Renders this action's own type-specific fields (e.g. header name/value). `null` for an action
33
- * type with nothing further to configure. */
34
- render: (action: A, onChange: (next: A) => void) => ReactNode;
35
- }
36
-
37
- export interface RuleBuilderValue<C extends object, A extends { type: string }> {
38
- enabled: boolean;
39
- sequence: number;
40
- stopProcessingRules: boolean;
41
- conditions: C;
42
- actions: A[];
43
- }
44
-
45
- export interface RuleBuilderProps<C extends object, A extends { type: string }> {
46
- value: RuleBuilderValue<C, A>;
47
- onChange: (next: RuleBuilderValue<C, A>) => void;
48
- conditionFields: ConditionFieldDef[];
49
- actionTypes: ActionTypeDef<A>[];
50
- /** Set by the page once the user has tried to save a rule `hasConditions()` rejects - turns the
51
- * "add at least one condition" hint into an error. */
52
- showValidation?: boolean;
53
- }
54
-
55
- /**
56
- * Whether `conditions` holds at least one real condition: a list with a non-blank entry, a ticked
57
- * boolean, or a chosen select value. A rule with none would match *every* message, so the pages that save
58
- * a `RuleBuilder` value (mail filters, transport rules) must refuse to save unless this returns `true`.
59
- */
60
- export function hasConditions(conditions: object): boolean {
61
- return Object.values(conditions as Record<string, unknown>).some((v) => {
62
- if (Array.isArray(v)) return v.some((entry) => typeof entry !== "string" || entry.trim() !== "");
63
- if (typeof v === "string") return v.trim() !== "";
64
- return v !== undefined && v !== null && v !== false;
65
- });
66
- }
67
-
68
- /**
69
- * Shared condition-row editor and enabled/sequence/stop-processing chrome for a rule that matches
70
- * conditions and runs actions in order — `TransportRule` (admin, org-wide) and `MailFilterRule`
71
- * (mailbox-scoped) both have this same `{enabled, sequence, stopProcessingRules, conditions, actions}`
72
- * shape. Conditions are driven by a declarative `conditionFields` list so both scopes share this one
73
- * editor despite their `Conditions` types not being identical — `MailFilterConditions.importance`
74
- * (`MailFilterRule`'s one condition field `TransportRuleConditions` has no equivalent of) is what forced
75
- * this list beyond `"list"`/`"boolean"` to add a third `"select"` kind for a small fixed-choice field,
76
- * confirming the shared editor still generalizes rather than needing a fork; actions are driven by a
77
- * small per-scope `actionTypes` registry instead, since the two action vocabularies (reject/quarantine/
78
- * add-header/add-recipient vs. move/copy/delete/mark-read/forward) are different enough that one
79
- * component special-casing both would be worse than this split.
80
- */
81
- export default function RuleBuilder<C extends object, A extends { type: string }>({
82
- value,
83
- onChange,
84
- conditionFields,
85
- actionTypes,
86
- showValidation = false,
87
- }: RuleBuilderProps<C, A>) {
88
- const [draftText, setDraftText] = useState<Record<string, string>>({});
89
- const [newActionType, setNewActionType] = useState(actionTypes[0]?.value ?? "");
90
- const conditions = value.conditions as Record<string, unknown>;
91
-
92
- function patch(next: Partial<RuleBuilderValue<C, A>>) {
93
- onChange({ ...value, ...next });
94
- }
95
-
96
- function addListEntry(key: string) {
97
- const text = (draftText[key] ?? "").trim();
98
- if (!text) return;
99
- const existing = (conditions[key] as string[] | undefined) ?? [];
100
- if (existing.includes(text)) return;
101
- patch({ conditions: { ...conditions, [key]: [...existing, text] } as C });
102
- setDraftText({ ...draftText, [key]: "" });
103
- }
104
-
105
- // Only invoked from a chip's own "Remove" button, which only renders while `conditions[key]` holds
106
- // that entry. Removing the last entry deletes the key rather than leaving an empty list behind.
107
- function removeListEntry(key: string, entry: string) {
108
- const remaining = (conditions[key] as string[]).filter((e) => e !== entry);
109
- const next = { ...conditions };
110
- if (remaining.length > 0) {
111
- next[key] = remaining;
112
- } else {
113
- delete next[key];
114
- }
115
- patch({ conditions: next as C });
116
- }
117
-
118
- function toggleBoolean(key: string) {
119
- // Unticking stores `undefined` (no condition), not `false`.
120
- patch({ conditions: { ...conditions, [key]: conditions[key] ? undefined : true } as C });
121
- }
122
-
123
- function setSelect(key: string, value: string) {
124
- patch({ conditions: { ...conditions, [key]: value || undefined } as C });
125
- }
126
-
127
- function addAction() {
128
- const def = actionTypes.find((t) => t.value === newActionType);
129
- if (!def) return;
130
- patch({ actions: [...value.actions, def.createDefault()] });
131
- }
132
-
133
- function updateAction(index: number, next: A) {
134
- patch({ actions: value.actions.map((a, i) => (i === index ? next : a)) });
135
- }
136
-
137
- function removeAction(index: number) {
138
- patch({ actions: value.actions.filter((_, i) => i !== index) });
139
- }
140
-
141
- return (
142
- <div className="flex flex-col gap-5">
143
- <div className="bg-surface border border-border rounded-md p-6">
144
- <h2 className="text-base font-bold uppercase tracking-wide mb-4">Conditions</h2>
145
- {!hasConditions(value.conditions) && (
146
- <p role={showValidation ? "alert" : undefined} className={`text-sm mb-4 ${showValidation ? "text-danger" : "text-text-muted"}`}>
147
- Add at least one condition. A rule without conditions would apply to every message.
148
- </p>
149
- )}
150
- <div className="flex flex-col gap-4">
151
- {conditionFields.map((field) => {
152
- if (field.kind === "boolean") {
153
- return (
154
- <label key={field.key} className="flex items-center gap-2 text-sm">
155
- <input
156
- type="checkbox"
157
- checked={!!conditions[field.key]}
158
- onChange={() => toggleBoolean(field.key)}
159
- />
160
- {field.label}
161
- </label>
162
- );
163
- }
164
- if (field.kind === "select") {
165
- return (
166
- <div key={field.key}>
167
- <label className="text-sm font-semibold mb-1.5 block" htmlFor={`condition-${field.key}`}>
168
- {field.label}
169
- </label>
170
- <select
171
- id={`condition-${field.key}`}
172
- className={INPUT_CLASS}
173
- value={(conditions[field.key] as string | undefined) ?? ""}
174
- onChange={(e) => setSelect(field.key, e.target.value)}
175
- >
176
- <option value="">Any</option>
177
- {(field.options ?? []).map((opt) => (
178
- <option key={opt.value} value={opt.value}>
179
- {opt.label}
180
- </option>
181
- ))}
182
- </select>
183
- </div>
184
- );
185
- }
186
- return (
187
- <div key={field.key}>
188
- <div className="text-sm font-semibold mb-1.5">{field.label}</div>
189
- <div className="flex flex-wrap gap-2 mb-2">
190
- {((conditions[field.key] as string[] | undefined) ?? []).map((entry) => (
191
- <span
192
- key={entry}
193
- className="inline-flex items-center gap-1.5 text-xs py-1 px-2.5 rounded-pill bg-surface-alt"
194
- >
195
- {entry}
196
- <button
197
- type="button"
198
- aria-label={`Remove ${entry}`}
199
- onClick={() => removeListEntry(field.key, entry)}
200
- className="text-text-muted hover:text-danger"
201
- >
202
- &times;
203
- </button>
204
- </span>
205
- ))}
206
- </div>
207
- <div className="flex gap-2">
208
- <input
209
- type="text"
210
- aria-label={field.label}
211
- className={INPUT_CLASS}
212
- placeholder={field.placeholder ?? "Add a value"}
213
- value={draftText[field.key] ?? ""}
214
- onChange={(e) => setDraftText({ ...draftText, [field.key]: e.target.value })}
215
- // Text typed but never added (no Add/Enter) is committed when the field
216
- // loses focus - e.g. to the page's Save button - so it's never silently
217
- // dropped from the saved rule.
218
- onBlur={() => addListEntry(field.key)}
219
- onKeyDown={(e) => {
220
- if (e.key === "Enter") {
221
- e.preventDefault();
222
- addListEntry(field.key);
223
- }
224
- }}
225
- />
226
- <Button
227
- type="button"
228
- variant="secondary"
229
- className="!w-auto"
230
- onClick={() => addListEntry(field.key)}
231
- >
232
- Add
233
- </Button>
234
- </div>
235
- </div>
236
- );
237
- })}
238
- </div>
239
- </div>
240
-
241
- <div className="bg-surface border border-border rounded-md p-6">
242
- <h2 className="text-base font-bold uppercase tracking-wide mb-4">Actions</h2>
243
- <div className="flex flex-col gap-3 mb-4">
244
- {value.actions.length === 0 && <p className="text-sm text-text-muted">No actions yet.</p>}
245
- {value.actions.map((action, index) => {
246
- const def = actionTypes.find((t) => t.value === action.type);
247
- return (
248
- <div key={index} className="border border-border rounded-sm p-3">
249
- <div className="flex items-center justify-between mb-2">
250
- <span className="text-sm font-semibold">{def?.label ?? action.type}</span>
251
- <button
252
- type="button"
253
- onClick={() => removeAction(index)}
254
- className="text-sm text-danger hover:underline"
255
- >
256
- Remove
257
- </button>
258
- </div>
259
- {def?.render(action, (next) => updateAction(index, next))}
260
- </div>
261
- );
262
- })}
263
- </div>
264
- <div className="flex gap-2">
265
- <select
266
- aria-label="New action type"
267
- className={INPUT_CLASS}
268
- value={newActionType}
269
- onChange={(e) => setNewActionType(e.target.value)}
270
- >
271
- {actionTypes.map((t) => (
272
- <option key={t.value} value={t.value}>
273
- {t.label}
274
- </option>
275
- ))}
276
- </select>
277
- <Button type="button" variant="secondary" className="!w-auto" onClick={addAction}>
278
- Add action
279
- </Button>
280
- </div>
281
- </div>
282
-
283
- <div className="bg-surface border border-border rounded-md p-6">
284
- <h2 className="text-base font-bold uppercase tracking-wide mb-4">Settings</h2>
285
- <div className="flex flex-col gap-3">
286
- <label className="flex items-center gap-2 text-sm">
287
- <input type="checkbox" checked={value.enabled} onChange={(e) => patch({ enabled: e.target.checked })} />
288
- Enabled
289
- </label>
290
- <label className="flex items-center gap-2 text-sm">
291
- <input
292
- type="checkbox"
293
- checked={value.stopProcessingRules}
294
- onChange={(e) => patch({ stopProcessingRules: e.target.checked })}
295
- />
296
- Stop processing more rules once this one matches
297
- </label>
298
- <label className="flex items-center gap-2 text-sm">
299
- Sequence (evaluation order, ascending)
300
- <input
301
- type="number"
302
- className="w-20 text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary"
303
- value={value.sequence}
304
- onChange={(e) => patch({ sequence: Number(e.target.value) })}
305
- />
306
- </label>
307
- </div>
308
- </div>
309
- </div>
310
- );
311
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ReactNode, useState } from "react";
6
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
7
+
8
+ const INPUT_CLASS =
9
+ "flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
10
+
11
+ /** One condition field this scope's `Conditions` shape supports — `"list"` renders an add/remove chip
12
+ * editor for a `string[]` field, `"boolean"` a plain checkbox, `"select"` a fixed-choice dropdown for a
13
+ * field with a small closed set of string values (e.g. `MailFilterConditions.importance`). */
14
+ export interface ConditionFieldDef {
15
+ key: string;
16
+ label: string;
17
+ kind: "list" | "boolean" | "select";
18
+ placeholder?: string;
19
+ /** Required when `kind` is `"select"` — the fixed set of values this field may hold, plus an
20
+ * implicit "Any" option that clears the field entirely. */
21
+ options?: { value: string; label: string }[];
22
+ }
23
+
24
+ /** One action type this scope's `Action` union supports — the per-scope registry the shared condition
25
+ * editor/chrome below is deliberately kept out of (transport-rule and mail-filter actions are
26
+ * different enough vocabularies that one component special-casing both would be worse than this). */
27
+ export interface ActionTypeDef<A> {
28
+ value: string;
29
+ label: string;
30
+ /** Builds a fresh default action of this type when picked from "Add action". */
31
+ createDefault: () => A;
32
+ /** Renders this action's own type-specific fields (e.g. header name/value). `null` for an action
33
+ * type with nothing further to configure. */
34
+ render: (action: A, onChange: (next: A) => void) => ReactNode;
35
+ }
36
+
37
+ export interface RuleBuilderValue<C extends object, A extends { type: string }> {
38
+ enabled: boolean;
39
+ sequence: number;
40
+ stopProcessingRules: boolean;
41
+ conditions: C;
42
+ actions: A[];
43
+ }
44
+
45
+ export interface RuleBuilderProps<C extends object, A extends { type: string }> {
46
+ value: RuleBuilderValue<C, A>;
47
+ onChange: (next: RuleBuilderValue<C, A>) => void;
48
+ conditionFields: ConditionFieldDef[];
49
+ actionTypes: ActionTypeDef<A>[];
50
+ /** Set by the page once the user has tried to save a rule `hasConditions()` rejects - turns the
51
+ * "add at least one condition" hint into an error. */
52
+ showValidation?: boolean;
53
+ }
54
+
55
+ /**
56
+ * Whether `conditions` holds at least one real condition: a list with a non-blank entry, a ticked
57
+ * boolean, or a chosen select value. A rule with none would match *every* message, so the pages that save
58
+ * a `RuleBuilder` value (mail filters, transport rules) must refuse to save unless this returns `true`.
59
+ */
60
+ export function hasConditions(conditions: object): boolean {
61
+ return Object.values(conditions as Record<string, unknown>).some((v) => {
62
+ if (Array.isArray(v)) return v.some((entry) => typeof entry !== "string" || entry.trim() !== "");
63
+ if (typeof v === "string") return v.trim() !== "";
64
+ return v !== undefined && v !== null && v !== false;
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Shared condition-row editor and enabled/sequence/stop-processing chrome for a rule that matches
70
+ * conditions and runs actions in order — `TransportRule` (admin, org-wide) and `MailFilterRule`
71
+ * (mailbox-scoped) both have this same `{enabled, sequence, stopProcessingRules, conditions, actions}`
72
+ * shape. Conditions are driven by a declarative `conditionFields` list so both scopes share this one
73
+ * editor despite their `Conditions` types not being identical — `MailFilterConditions.importance`
74
+ * (`MailFilterRule`'s one condition field `TransportRuleConditions` has no equivalent of) is what forced
75
+ * this list beyond `"list"`/`"boolean"` to add a third `"select"` kind for a small fixed-choice field,
76
+ * confirming the shared editor still generalizes rather than needing a fork; actions are driven by a
77
+ * small per-scope `actionTypes` registry instead, since the two action vocabularies (reject/quarantine/
78
+ * add-header/add-recipient vs. move/copy/delete/mark-read/forward) are different enough that one
79
+ * component special-casing both would be worse than this split.
80
+ */
81
+ export default function RuleBuilder<C extends object, A extends { type: string }>({
82
+ value,
83
+ onChange,
84
+ conditionFields,
85
+ actionTypes,
86
+ showValidation = false,
87
+ }: RuleBuilderProps<C, A>) {
88
+ const [draftText, setDraftText] = useState<Record<string, string>>({});
89
+ const [newActionType, setNewActionType] = useState(actionTypes[0]?.value ?? "");
90
+ const conditions = value.conditions as Record<string, unknown>;
91
+
92
+ function patch(next: Partial<RuleBuilderValue<C, A>>) {
93
+ onChange({ ...value, ...next });
94
+ }
95
+
96
+ function addListEntry(key: string) {
97
+ const text = (draftText[key] ?? "").trim();
98
+ if (!text) return;
99
+ const existing = (conditions[key] as string[] | undefined) ?? [];
100
+ if (existing.includes(text)) return;
101
+ patch({ conditions: { ...conditions, [key]: [...existing, text] } as C });
102
+ setDraftText({ ...draftText, [key]: "" });
103
+ }
104
+
105
+ // Only invoked from a chip's own "Remove" button, which only renders while `conditions[key]` holds
106
+ // that entry. Removing the last entry deletes the key rather than leaving an empty list behind.
107
+ function removeListEntry(key: string, entry: string) {
108
+ const remaining = (conditions[key] as string[]).filter((e) => e !== entry);
109
+ const next = { ...conditions };
110
+ if (remaining.length > 0) {
111
+ next[key] = remaining;
112
+ } else {
113
+ delete next[key];
114
+ }
115
+ patch({ conditions: next as C });
116
+ }
117
+
118
+ function toggleBoolean(key: string) {
119
+ // Unticking stores `undefined` (no condition), not `false`.
120
+ patch({ conditions: { ...conditions, [key]: conditions[key] ? undefined : true } as C });
121
+ }
122
+
123
+ function setSelect(key: string, value: string) {
124
+ patch({ conditions: { ...conditions, [key]: value || undefined } as C });
125
+ }
126
+
127
+ function addAction() {
128
+ const def = actionTypes.find((t) => t.value === newActionType);
129
+ if (!def) return;
130
+ patch({ actions: [...value.actions, def.createDefault()] });
131
+ }
132
+
133
+ function updateAction(index: number, next: A) {
134
+ patch({ actions: value.actions.map((a, i) => (i === index ? next : a)) });
135
+ }
136
+
137
+ function removeAction(index: number) {
138
+ patch({ actions: value.actions.filter((_, i) => i !== index) });
139
+ }
140
+
141
+ return (
142
+ <div className="flex flex-col gap-5">
143
+ <div className="bg-surface border border-border rounded-md p-6">
144
+ <h2 className="text-base font-bold uppercase tracking-wide mb-4">Conditions</h2>
145
+ {!hasConditions(value.conditions) && (
146
+ <p role={showValidation ? "alert" : undefined} className={`text-sm mb-4 ${showValidation ? "text-danger" : "text-text-muted"}`}>
147
+ Add at least one condition. A rule without conditions would apply to every message.
148
+ </p>
149
+ )}
150
+ <div className="flex flex-col gap-4">
151
+ {conditionFields.map((field) => {
152
+ if (field.kind === "boolean") {
153
+ return (
154
+ <label key={field.key} className="flex items-center gap-2 text-sm">
155
+ <input
156
+ type="checkbox"
157
+ checked={!!conditions[field.key]}
158
+ onChange={() => toggleBoolean(field.key)}
159
+ />
160
+ {field.label}
161
+ </label>
162
+ );
163
+ }
164
+ if (field.kind === "select") {
165
+ return (
166
+ <div key={field.key}>
167
+ <label className="text-sm font-semibold mb-1.5 block" htmlFor={`condition-${field.key}`}>
168
+ {field.label}
169
+ </label>
170
+ <select
171
+ id={`condition-${field.key}`}
172
+ className={INPUT_CLASS}
173
+ value={(conditions[field.key] as string | undefined) ?? ""}
174
+ onChange={(e) => setSelect(field.key, e.target.value)}
175
+ >
176
+ <option value="">Any</option>
177
+ {(field.options ?? []).map((opt) => (
178
+ <option key={opt.value} value={opt.value}>
179
+ {opt.label}
180
+ </option>
181
+ ))}
182
+ </select>
183
+ </div>
184
+ );
185
+ }
186
+ return (
187
+ <div key={field.key}>
188
+ <div className="text-sm font-semibold mb-1.5">{field.label}</div>
189
+ <div className="flex flex-wrap gap-2 mb-2">
190
+ {((conditions[field.key] as string[] | undefined) ?? []).map((entry) => (
191
+ <span
192
+ key={entry}
193
+ className="inline-flex items-center gap-1.5 text-xs py-1 px-2.5 rounded-pill bg-surface-alt"
194
+ >
195
+ {entry}
196
+ <button
197
+ type="button"
198
+ aria-label={`Remove ${entry}`}
199
+ onClick={() => removeListEntry(field.key, entry)}
200
+ className="text-text-muted hover:text-danger"
201
+ >
202
+ &times;
203
+ </button>
204
+ </span>
205
+ ))}
206
+ </div>
207
+ <div className="flex gap-2">
208
+ <input
209
+ type="text"
210
+ aria-label={field.label}
211
+ className={INPUT_CLASS}
212
+ placeholder={field.placeholder ?? "Add a value"}
213
+ value={draftText[field.key] ?? ""}
214
+ onChange={(e) => setDraftText({ ...draftText, [field.key]: e.target.value })}
215
+ // Text typed but never added (no Add/Enter) is committed when the field
216
+ // loses focus - e.g. to the page's Save button - so it's never silently
217
+ // dropped from the saved rule.
218
+ onBlur={() => addListEntry(field.key)}
219
+ onKeyDown={(e) => {
220
+ if (e.key === "Enter") {
221
+ e.preventDefault();
222
+ addListEntry(field.key);
223
+ }
224
+ }}
225
+ />
226
+ <Button
227
+ type="button"
228
+ variant="secondary"
229
+ className="!w-auto"
230
+ onClick={() => addListEntry(field.key)}
231
+ >
232
+ Add
233
+ </Button>
234
+ </div>
235
+ </div>
236
+ );
237
+ })}
238
+ </div>
239
+ </div>
240
+
241
+ <div className="bg-surface border border-border rounded-md p-6">
242
+ <h2 className="text-base font-bold uppercase tracking-wide mb-4">Actions</h2>
243
+ <div className="flex flex-col gap-3 mb-4">
244
+ {value.actions.length === 0 && <p className="text-sm text-text-muted">No actions yet.</p>}
245
+ {value.actions.map((action, index) => {
246
+ const def = actionTypes.find((t) => t.value === action.type);
247
+ return (
248
+ <div key={index} className="border border-border rounded-sm p-3">
249
+ <div className="flex items-center justify-between mb-2">
250
+ <span className="text-sm font-semibold">{def?.label ?? action.type}</span>
251
+ <button
252
+ type="button"
253
+ onClick={() => removeAction(index)}
254
+ className="text-sm text-danger hover:underline"
255
+ >
256
+ Remove
257
+ </button>
258
+ </div>
259
+ {def?.render(action, (next) => updateAction(index, next))}
260
+ </div>
261
+ );
262
+ })}
263
+ </div>
264
+ <div className="flex gap-2">
265
+ <select
266
+ aria-label="New action type"
267
+ className={INPUT_CLASS}
268
+ value={newActionType}
269
+ onChange={(e) => setNewActionType(e.target.value)}
270
+ >
271
+ {actionTypes.map((t) => (
272
+ <option key={t.value} value={t.value}>
273
+ {t.label}
274
+ </option>
275
+ ))}
276
+ </select>
277
+ <Button type="button" variant="secondary" className="!w-auto" onClick={addAction}>
278
+ Add action
279
+ </Button>
280
+ </div>
281
+ </div>
282
+
283
+ <div className="bg-surface border border-border rounded-md p-6">
284
+ <h2 className="text-base font-bold uppercase tracking-wide mb-4">Settings</h2>
285
+ <div className="flex flex-col gap-3">
286
+ <label className="flex items-center gap-2 text-sm">
287
+ <input type="checkbox" checked={value.enabled} onChange={(e) => patch({ enabled: e.target.checked })} />
288
+ Enabled
289
+ </label>
290
+ <label className="flex items-center gap-2 text-sm">
291
+ <input
292
+ type="checkbox"
293
+ checked={value.stopProcessingRules}
294
+ onChange={(e) => patch({ stopProcessingRules: e.target.checked })}
295
+ />
296
+ Stop processing more rules once this one matches
297
+ </label>
298
+ <label className="flex items-center gap-2 text-sm">
299
+ Sequence (evaluation order, ascending)
300
+ <input
301
+ type="number"
302
+ className="w-20 text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary"
303
+ value={value.sequence}
304
+ onChange={(e) => patch({ sequence: Number(e.target.value) })}
305
+ />
306
+ </label>
307
+ </div>
308
+ </div>
309
+ </div>
310
+ );
311
+ }