@rapidmx/web-client 0.2.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 (168) hide show
  1. package/LICENSE +382 -0
  2. package/apps/admin/_404.tsx +9 -0
  3. package/apps/admin/_500.tsx +9 -0
  4. package/apps/admin/_layout.tsx +41 -0
  5. package/apps/admin/audit-log/index.tsx +187 -0
  6. package/apps/admin/branding/index.tsx +404 -0
  7. package/apps/admin/distribution-lists/[uid].tsx +70 -0
  8. package/apps/admin/distribution-lists/index.tsx +115 -0
  9. package/apps/admin/distribution-lists/new/index.tsx +114 -0
  10. package/apps/admin/domains/[uid].tsx +259 -0
  11. package/apps/admin/domains/index.tsx +125 -0
  12. package/apps/admin/domains/new/index.tsx +84 -0
  13. package/apps/admin/index.tsx +82 -0
  14. package/apps/admin/ingest-queue/index.tsx +116 -0
  15. package/apps/admin/mailboxes/[uid].tsx +126 -0
  16. package/apps/admin/mailboxes/new/index.tsx +291 -0
  17. package/apps/admin/quarantine/index.tsx +171 -0
  18. package/apps/admin/transport-rules/[uid].tsx +143 -0
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
  20. package/apps/admin/transport-rules/index.tsx +117 -0
  21. package/apps/admin/transport-rules/new/index.tsx +105 -0
  22. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
  23. package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
  24. package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
  25. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
  26. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
  27. package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
  28. package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
  29. package/apps/shared/components/calendar/EventModal.tsx +513 -0
  30. package/apps/shared/components/calendar/MonthView.tsx +139 -0
  31. package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
  32. package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
  33. package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
  34. package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
  35. package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
  36. package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
  37. package/apps/shared/components/contacts/ContactForm.tsx +314 -0
  38. package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
  39. package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
  40. package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
  41. package/apps/shared/components/layout/AppShell.tsx +165 -0
  42. package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
  43. package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
  44. package/apps/shared/components/layout/UserMenu.tsx +150 -0
  45. package/apps/shared/components/mail/ConversationList.tsx +61 -0
  46. package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
  47. package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
  48. package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
  49. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
  50. package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
  51. package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
  52. package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
  53. package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
  54. package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
  55. package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
  56. package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
  57. package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
  58. package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
  59. package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
  60. package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
  61. package/apps/shared/styles/app.css +265 -0
  62. package/apps/www/_404.tsx +9 -0
  63. package/apps/www/_500.tsx +9 -0
  64. package/apps/www/_layout.tsx +45 -0
  65. package/apps/www/calendar/index.tsx +407 -0
  66. package/apps/www/contacts/[uid].tsx +88 -0
  67. package/apps/www/contacts/index.tsx +436 -0
  68. package/apps/www/index.tsx +375 -0
  69. package/apps/www/messages/[uid].tsx +70 -0
  70. package/apps/www/settings/auto-reply/index.tsx +129 -0
  71. package/apps/www/settings/booking-types/[uid].tsx +300 -0
  72. package/apps/www/settings/booking-types/index.tsx +98 -0
  73. package/apps/www/settings/booking-types/new/index.tsx +212 -0
  74. package/apps/www/settings/filters/[uid].tsx +155 -0
  75. package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
  76. package/apps/www/settings/filters/index.tsx +99 -0
  77. package/apps/www/settings/filters/new/index.tsx +126 -0
  78. package/apps/www/settings/focused-inbox/index.tsx +148 -0
  79. package/apps/www/settings/read-receipts/index.tsx +125 -0
  80. package/apps/www/settings/signatures/[uid].tsx +167 -0
  81. package/apps/www/settings/signatures/index.tsx +85 -0
  82. package/apps/www/settings/signatures/new/index.tsx +129 -0
  83. package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
  84. package/apps/www/tasks/index.tsx +530 -0
  85. package/dist/apps/admin/_404.js +4 -0
  86. package/dist/apps/admin/_500.js +4 -0
  87. package/dist/apps/admin/_layout.js +10 -0
  88. package/dist/apps/admin/audit-log/index.js +66 -0
  89. package/dist/apps/admin/branding/index.js +126 -0
  90. package/dist/apps/admin/distribution-lists/[uid].js +34 -0
  91. package/dist/apps/admin/distribution-lists/index.js +31 -0
  92. package/dist/apps/admin/distribution-lists/new/index.js +51 -0
  93. package/dist/apps/admin/domains/[uid].js +102 -0
  94. package/dist/apps/admin/domains/index.js +31 -0
  95. package/dist/apps/admin/domains/new/index.js +41 -0
  96. package/dist/apps/admin/index.js +32 -0
  97. package/dist/apps/admin/ingest-queue/index.js +48 -0
  98. package/dist/apps/admin/mailboxes/[uid].js +59 -0
  99. package/dist/apps/admin/mailboxes/new/index.js +93 -0
  100. package/dist/apps/admin/quarantine/index.js +66 -0
  101. package/dist/apps/admin/transport-rules/[uid].js +87 -0
  102. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
  103. package/dist/apps/admin/transport-rules/index.js +32 -0
  104. package/dist/apps/admin/transport-rules/new/index.js +50 -0
  105. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
  106. package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
  107. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
  108. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
  109. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
  110. package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
  111. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
  112. package/dist/apps/shared/components/calendar/EventModal.js +167 -0
  113. package/dist/apps/shared/components/calendar/MonthView.js +38 -0
  114. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
  115. package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
  116. package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
  117. package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
  118. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
  119. package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
  120. package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
  121. package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
  122. package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
  123. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
  124. package/dist/apps/shared/components/layout/AppShell.js +68 -0
  125. package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
  126. package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
  127. package/dist/apps/shared/components/layout/UserMenu.js +59 -0
  128. package/dist/apps/shared/components/mail/ConversationList.js +17 -0
  129. package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
  130. package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
  131. package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
  132. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
  133. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
  134. package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
  135. package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
  136. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
  137. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
  138. package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
  139. package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
  140. package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
  141. package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
  142. package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
  143. package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
  144. package/dist/apps/shared/styles/app.css +265 -0
  145. package/dist/apps/www/_404.js +4 -0
  146. package/dist/apps/www/_500.js +4 -0
  147. package/dist/apps/www/_layout.js +8 -0
  148. package/dist/apps/www/calendar/index.js +228 -0
  149. package/dist/apps/www/contacts/[uid].js +62 -0
  150. package/dist/apps/www/contacts/index.js +262 -0
  151. package/dist/apps/www/index.js +228 -0
  152. package/dist/apps/www/messages/[uid].js +47 -0
  153. package/dist/apps/www/settings/auto-reply/index.js +54 -0
  154. package/dist/apps/www/settings/booking-types/[uid].js +129 -0
  155. package/dist/apps/www/settings/booking-types/index.js +29 -0
  156. package/dist/apps/www/settings/booking-types/new/index.js +86 -0
  157. package/dist/apps/www/settings/filters/[uid].js +94 -0
  158. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
  159. package/dist/apps/www/settings/filters/index.js +30 -0
  160. package/dist/apps/www/settings/filters/new/index.js +61 -0
  161. package/dist/apps/www/settings/focused-inbox/index.js +69 -0
  162. package/dist/apps/www/settings/read-receipts/index.js +51 -0
  163. package/dist/apps/www/settings/signatures/[uid].js +96 -0
  164. package/dist/apps/www/settings/signatures/index.js +34 -0
  165. package/dist/apps/www/settings/signatures/new/index.js +63 -0
  166. package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
  167. package/dist/apps/www/tasks/index.js +280 -0
  168. package/package.json +87 -0
@@ -0,0 +1,171 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { listQuarantine, QuarantineEntry, releaseQuarantineEntry } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+
12
+ const PAGE_SIZE = 25;
13
+
14
+ export function readMailboxUid(): string | null {
15
+ if (typeof window === "undefined") return null;
16
+ return new URLSearchParams(window.location.search).get("mailboxUid");
17
+ }
18
+
19
+ export default function QuarantinePage(props: Omit<AdminShellProps, "active"> & { userUid?: string }) {
20
+ return (
21
+ <AdminShell {...props} active="quarantine">
22
+ <QuarantineContent userUid={props.userUid} />
23
+ </AdminShell>
24
+ );
25
+ }
26
+
27
+ function QuarantineContent({ userUid }: { userUid?: string }) {
28
+ const [mailboxUid, setMailboxUid] = useState<string | null>(null);
29
+ const [page, setPage] = useState(0);
30
+ const [entries, setEntries] = useState<QuarantineEntry[]>([]);
31
+ const [loading, setLoading] = useState(true);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const [releasing, setReleasing] = useState<string | null>(null);
34
+
35
+ useEffect(() => {
36
+ setMailboxUid(readMailboxUid());
37
+ }, []);
38
+
39
+ function reload(uid: string) {
40
+ setLoading(true);
41
+ setError(null);
42
+ listQuarantine(uid, { page, limit: PAGE_SIZE })
43
+ .then(setEntries)
44
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load quarantine."))
45
+ .finally(() => setLoading(false));
46
+ }
47
+
48
+ useEffect(() => {
49
+ if (mailboxUid) {
50
+ reload(mailboxUid);
51
+ }
52
+ }, [mailboxUid, page]);
53
+
54
+ const hasNextPage = entries.length === PAGE_SIZE;
55
+
56
+ // Only ever invoked from the "Release" button below, which itself only renders once `mailboxUid` is
57
+ // known and `AdminShell` has already confirmed `userUid` (children only render once authorized) — both
58
+ // non-null assertions reflect that real invariant, not an unchecked assumption.
59
+ async function handleRelease(entry: QuarantineEntry) {
60
+ setReleasing(entry.uid);
61
+ setError(null);
62
+ try {
63
+ await releaseQuarantineEntry(entry.uid, entry.version, userUid!);
64
+ reload(mailboxUid!);
65
+ } catch (err) {
66
+ setError(err instanceof ApiRequestError ? err.message : "Could not release this message.");
67
+ } finally {
68
+ setReleasing(null);
69
+ }
70
+ }
71
+
72
+ if (!mailboxUid) {
73
+ return <Alert>No mailbox specified. Open a mailbox's detail page and choose "View quarantine".</Alert>;
74
+ }
75
+
76
+ return (
77
+ <div className="max-w-4xl flex flex-col gap-4">
78
+ <div>
79
+ <a
80
+ href={`/admin/mailboxes/${encodeURIComponent(mailboxUid)}`}
81
+ className="text-sm text-primary-dark hover:underline"
82
+ >
83
+ &larr; Back to mailbox
84
+ </a>
85
+ <h1 className="text-xl font-bold uppercase tracking-wide mt-1">Quarantine</h1>
86
+ </div>
87
+
88
+ {error && <Alert>{error}</Alert>}
89
+
90
+ {loading ? (
91
+ <p className="text-sm text-text-muted">Loading&hellip;</p>
92
+ ) : entries.length === 0 ? (
93
+ <p className="text-sm text-text-muted">Nothing quarantined for this mailbox.</p>
94
+ ) : (
95
+ <div className="overflow-x-auto">
96
+ <table className="w-full text-sm border-collapse">
97
+ <thead>
98
+ <tr>
99
+ {["Reason", "Quarantined", "Status", ""].map((h) => (
100
+ <th
101
+ key={h}
102
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
103
+ >
104
+ {h}
105
+ </th>
106
+ ))}
107
+ </tr>
108
+ </thead>
109
+ <tbody>
110
+ {entries.map((entry) => (
111
+ <tr key={entry.uid}>
112
+ <td className="py-2.5 px-2.5 border-b border-border">{entry.reason}</td>
113
+ <td className="py-2.5 px-2.5 border-b border-border">
114
+ {new Date(entry.dateCreated).toLocaleString()}
115
+ </td>
116
+ <td className="py-2.5 px-2.5 border-b border-border">
117
+ {entry.releasedAt ? (
118
+ <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white">
119
+ Released
120
+ </span>
121
+ ) : (
122
+ <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">
123
+ Held
124
+ </span>
125
+ )}
126
+ </td>
127
+ <td className="py-2.5 px-2.5 border-b border-border text-right">
128
+ {!entry.releasedAt && (
129
+ <Button
130
+ type="button"
131
+ variant="secondary"
132
+ className="!w-auto"
133
+ loading={releasing === entry.uid}
134
+ disabled={releasing === entry.uid}
135
+ onClick={() => handleRelease(entry)}
136
+ >
137
+ Release
138
+ </Button>
139
+ )}
140
+ </td>
141
+ </tr>
142
+ ))}
143
+ </tbody>
144
+ </table>
145
+ </div>
146
+ )}
147
+
148
+ <div className="flex gap-3 items-center mt-4">
149
+ <Button
150
+ variant="secondary"
151
+ type="button"
152
+ className="!w-auto"
153
+ disabled={page === 0 || loading}
154
+ onClick={() => setPage((p) => Math.max(0, p - 1))}
155
+ >
156
+ Previous
157
+ </Button>
158
+ <span className="text-sm text-text-muted">Page {page + 1}</span>
159
+ <Button
160
+ variant="secondary"
161
+ type="button"
162
+ className="!w-auto"
163
+ disabled={!hasNextPage || loading}
164
+ onClick={() => setPage((p) => p + 1)}
165
+ >
166
+ Next
167
+ </Button>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
@@ -0,0 +1,143 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import {
8
+ getTransportRule,
9
+ TransportRule,
10
+ TransportRuleAction,
11
+ TransportRuleConditions,
12
+ updateTransportRule,
13
+ } from "@rapidmx/react-shared/admin/transportRulesApi.js";
14
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
15
+ import RuleBuilder, { RuleBuilderValue } from "../../shared/components/rules/RuleBuilder.js";
16
+ import { TRANSPORT_RULE_ACTION_TYPES, TRANSPORT_RULE_CONDITION_FIELDS } from "./_transportRuleConfig.js";
17
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
18
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
19
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
20
+
21
+ const INPUT_CLASS =
22
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
23
+
24
+ export default function TransportRuleDetailPage(props: Omit<AdminShellProps, "active"> & { params: { uid: string } }) {
25
+ return (
26
+ <AdminShell {...props} active="transportRules">
27
+ <TransportRuleDetailContent uid={props.params.uid} />
28
+ </AdminShell>
29
+ );
30
+ }
31
+
32
+ function TransportRuleDetailContent({ uid }: { uid: string }) {
33
+ const [original, setOriginal] = useState<TransportRule | null>(null);
34
+ const [name, setName] = useState("");
35
+ const [rule, setRule] = useState<RuleBuilderValue<TransportRuleConditions, TransportRuleAction> | null>(null);
36
+ const [loading, setLoading] = useState(true);
37
+ const [error, setError] = useState<string | null>(null);
38
+ const [saving, setSaving] = useState(false);
39
+ const [saved, setSaved] = useState(false);
40
+
41
+ useEffect(() => {
42
+ setLoading(true);
43
+ setError(null);
44
+ getTransportRule(uid)
45
+ .then((loaded) => {
46
+ setOriginal(loaded);
47
+ if (loaded) {
48
+ setName(loaded.name);
49
+ setRule({
50
+ enabled: loaded.enabled,
51
+ sequence: loaded.sequence,
52
+ stopProcessingRules: loaded.stopProcessingRules,
53
+ conditions: loaded.conditions,
54
+ actions: loaded.actions,
55
+ });
56
+ }
57
+ })
58
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this transport rule."))
59
+ .finally(() => setLoading(false));
60
+ }, [uid]);
61
+
62
+ // Only ever invoked from the form below, which itself only renders once `original`/`rule` are
63
+ // loaded (the early returns above cover every other state) — the non-null assertions reflect that
64
+ // real invariant, not an unchecked assumption. Matches `QuarantineContent.handleRelease`'s and
65
+ // `DomainDetailContent.handleVerify`'s identical pattern.
66
+ async function handleSubmit(e: FormEvent) {
67
+ e.preventDefault();
68
+ setError(null);
69
+
70
+ if (!name.trim()) {
71
+ setError("A name is required.");
72
+ return;
73
+ }
74
+
75
+ setSaving(true);
76
+ setSaved(false);
77
+ try {
78
+ const updated = await updateTransportRule({
79
+ uid: original!.uid,
80
+ version: original!.version,
81
+ name: name.trim(),
82
+ ...rule!,
83
+ });
84
+ setOriginal(updated);
85
+ setSaved(true);
86
+ } catch (err) {
87
+ setError(err instanceof ApiRequestError ? err.message : "Could not save this transport rule.");
88
+ } finally {
89
+ setSaving(false);
90
+ }
91
+ }
92
+
93
+ if (loading) {
94
+ return <p className="text-sm text-text-muted">Loading&hellip;</p>;
95
+ }
96
+ if (error && !original) {
97
+ return <Alert>{error}</Alert>;
98
+ }
99
+ if (!original || !rule) {
100
+ return <Alert>Transport rule not found.</Alert>;
101
+ }
102
+
103
+ return (
104
+ <div className="max-w-3xl">
105
+ <a href="/admin/transport-rules" className="text-sm text-primary-dark hover:underline">
106
+ &larr; All transport rules
107
+ </a>
108
+ <h1 className="text-xl font-bold tracking-tight mt-1 mb-5">{original.name}</h1>
109
+
110
+ {error && <Alert>{error}</Alert>}
111
+ {saved && !error && (
112
+ <div className="mb-4 text-sm text-success font-medium">Saved.</div>
113
+ )}
114
+
115
+ <form onSubmit={handleSubmit} className="flex flex-col gap-5">
116
+ <div className="bg-surface border border-border rounded-md p-6">
117
+ <FormField label="Name" htmlFor="name">
118
+ <input
119
+ id="name"
120
+ type="text"
121
+ className={INPUT_CLASS}
122
+ value={name}
123
+ onChange={(e) => setName(e.target.value)}
124
+ />
125
+ </FormField>
126
+ </div>
127
+
128
+ <RuleBuilder
129
+ value={rule}
130
+ onChange={setRule}
131
+ conditionFields={TRANSPORT_RULE_CONDITION_FIELDS}
132
+ actionTypes={TRANSPORT_RULE_ACTION_TYPES}
133
+ />
134
+
135
+ <div>
136
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
137
+ Save changes
138
+ </Button>
139
+ </div>
140
+ </form>
141
+ </div>
142
+ );
143
+ }
@@ -0,0 +1,75 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { TransportRuleAction } from "@rapidmx/react-shared/admin/transportRulesApi.js";
7
+ import { ActionTypeDef, ConditionFieldDef } from "../../shared/components/rules/RuleBuilder.js";
8
+
9
+ const FIELD_INPUT_CLASS =
10
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
11
+
12
+ export const TRANSPORT_RULE_CONDITION_FIELDS: ConditionFieldDef[] = [
13
+ { key: "fromContains", label: "From contains", kind: "list" },
14
+ { key: "subjectContains", label: "Subject contains", kind: "list" },
15
+ { key: "bodyContains", label: "Body contains", kind: "list" },
16
+ { key: "recipientContains", label: "Any recipient contains", kind: "list" },
17
+ { key: "anyRecipientExternal", label: "Any recipient is external", kind: "boolean" },
18
+ { key: "hasAttachment", label: "Has an attachment", kind: "boolean" },
19
+ { key: "attachmentNameContains", label: "Attachment filename contains", kind: "list" },
20
+ ];
21
+
22
+ export const TRANSPORT_RULE_ACTION_TYPES: ActionTypeDef<TransportRuleAction>[] = [
23
+ {
24
+ value: "reject",
25
+ label: "Reject the message",
26
+ createDefault: () => ({ type: "reject" }),
27
+ render: () => null,
28
+ },
29
+ {
30
+ value: "quarantine",
31
+ label: "Quarantine every recipient's copy",
32
+ createDefault: () => ({ type: "quarantine" }),
33
+ render: () => null,
34
+ },
35
+ {
36
+ value: "add_header",
37
+ label: "Add a header",
38
+ createDefault: () => ({ type: "add_header", headerName: "", headerValue: "" }),
39
+ render: (action, onChange) => (
40
+ <div className="flex gap-2">
41
+ <input
42
+ type="text"
43
+ aria-label="Header name"
44
+ className={FIELD_INPUT_CLASS}
45
+ placeholder="X-Compliance-Flag"
46
+ value={action.headerName ?? ""}
47
+ onChange={(e) => onChange({ ...action, headerName: e.target.value })}
48
+ />
49
+ <input
50
+ type="text"
51
+ aria-label="Header value"
52
+ className={FIELD_INPUT_CLASS}
53
+ placeholder="reviewed"
54
+ value={action.headerValue ?? ""}
55
+ onChange={(e) => onChange({ ...action, headerValue: e.target.value })}
56
+ />
57
+ </div>
58
+ ),
59
+ },
60
+ {
61
+ value: "add_recipient",
62
+ label: "Deliver an additional copy to",
63
+ createDefault: () => ({ type: "add_recipient", recipientAddress: "" }),
64
+ render: (action, onChange) => (
65
+ <input
66
+ type="email"
67
+ aria-label="Recipient address"
68
+ className={FIELD_INPUT_CLASS}
69
+ placeholder="compliance@example.com"
70
+ value={action.recipientAddress ?? ""}
71
+ onChange={(e) => onChange({ ...action, recipientAddress: e.target.value })}
72
+ />
73
+ ),
74
+ },
75
+ ];
@@ -0,0 +1,117 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { listTransportRules, TransportRule } from "@rapidmx/react-shared/admin/transportRulesApi.js";
8
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+
12
+ const PAGE_SIZE = 25;
13
+
14
+ export default function TransportRulesPage(props: Omit<AdminShellProps, "active">) {
15
+ return (
16
+ <AdminShell {...props} active="transportRules">
17
+ <TransportRulesContent />
18
+ </AdminShell>
19
+ );
20
+ }
21
+
22
+ function TransportRulesContent() {
23
+ const [page, setPage] = useState(0);
24
+ const [rules, setRules] = useState<TransportRule[]>([]);
25
+ const [loading, setLoading] = useState(true);
26
+ const [error, setError] = useState<string | null>(null);
27
+
28
+ useEffect(() => {
29
+ setLoading(true);
30
+ setError(null);
31
+ listTransportRules({ page, limit: PAGE_SIZE })
32
+ .then(setRules)
33
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load transport rules."))
34
+ .finally(() => setLoading(false));
35
+ }, [page]);
36
+
37
+ const hasNextPage = rules.length === PAGE_SIZE;
38
+ const sorted = [...rules].sort((a, b) => a.sequence - b.sequence);
39
+
40
+ return (
41
+ <>
42
+ <div className="flex items-center justify-between mb-5">
43
+ <h1 className="text-xl font-bold uppercase tracking-wide">Transport rules</h1>
44
+ <a href="/admin/transport-rules/new">
45
+ <Button type="button" className="!w-auto">
46
+ + New transport rule
47
+ </Button>
48
+ </a>
49
+ </div>
50
+
51
+ {error && <Alert>{error}</Alert>}
52
+
53
+ {loading ? (
54
+ <p className="text-sm text-text-muted">Loading&hellip;</p>
55
+ ) : sorted.length === 0 ? (
56
+ <p className="text-sm text-text-muted">No transport rules yet.</p>
57
+ ) : (
58
+ <div className="overflow-x-auto">
59
+ <table className="w-full text-sm border-collapse">
60
+ <thead>
61
+ <tr>
62
+ {["Sequence", "Name", "Enabled", "Actions", ""].map((h) => (
63
+ <th
64
+ key={h}
65
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
66
+ >
67
+ {h}
68
+ </th>
69
+ ))}
70
+ </tr>
71
+ </thead>
72
+ <tbody>
73
+ {sorted.map((rule) => (
74
+ <tr key={rule.uid}>
75
+ <td className="py-2.5 px-2.5 border-b border-border">{rule.sequence}</td>
76
+ <td className="py-2.5 px-2.5 border-b border-border">{rule.name}</td>
77
+ <td className="py-2.5 px-2.5 border-b border-border">{rule.enabled ? "Yes" : "No"}</td>
78
+ <td className="py-2.5 px-2.5 border-b border-border">{rule.actions.length}</td>
79
+ <td className="py-2.5 px-2.5 border-b border-border text-right">
80
+ <a
81
+ href={`/admin/transport-rules/${encodeURIComponent(rule.uid)}`}
82
+ className="text-primary-dark hover:underline font-medium"
83
+ >
84
+ View
85
+ </a>
86
+ </td>
87
+ </tr>
88
+ ))}
89
+ </tbody>
90
+ </table>
91
+ </div>
92
+ )}
93
+
94
+ <div className="flex gap-3 items-center mt-4">
95
+ <Button
96
+ variant="secondary"
97
+ type="button"
98
+ className="!w-auto"
99
+ disabled={page === 0 || loading}
100
+ onClick={() => setPage((p) => Math.max(0, p - 1))}
101
+ >
102
+ Previous
103
+ </Button>
104
+ <span className="text-sm text-text-muted">Page {page + 1}</span>
105
+ <Button
106
+ variant="secondary"
107
+ type="button"
108
+ className="!w-auto"
109
+ disabled={!hasNextPage || loading}
110
+ onClick={() => setPage((p) => p + 1)}
111
+ >
112
+ Next
113
+ </Button>
114
+ </div>
115
+ </>
116
+ );
117
+ }
@@ -0,0 +1,105 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import {
8
+ createTransportRule,
9
+ TransportRuleAction,
10
+ TransportRuleConditions,
11
+ } from "@rapidmx/react-shared/admin/transportRulesApi.js";
12
+ import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
13
+ import RuleBuilder, { RuleBuilderValue } from "../../../shared/components/rules/RuleBuilder.js";
14
+ import { TRANSPORT_RULE_ACTION_TYPES, TRANSPORT_RULE_CONDITION_FIELDS } from "../_transportRuleConfig.js";
15
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
16
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
17
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
18
+
19
+ const INPUT_CLASS =
20
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
21
+
22
+ export default function NewTransportRulePage(props: Omit<AdminShellProps, "active">) {
23
+ return (
24
+ <AdminShell {...props} active="transportRules">
25
+ <NewTransportRuleForm />
26
+ </AdminShell>
27
+ );
28
+ }
29
+
30
+ function NewTransportRuleForm() {
31
+ const [name, setName] = useState("");
32
+ const [rule, setRule] = useState<RuleBuilderValue<TransportRuleConditions, TransportRuleAction>>({
33
+ enabled: true,
34
+ sequence: 0,
35
+ stopProcessingRules: false,
36
+ conditions: {},
37
+ actions: [],
38
+ });
39
+ const [error, setError] = useState<string | null>(null);
40
+ const [saving, setSaving] = useState(false);
41
+
42
+ async function handleSubmit(e: FormEvent) {
43
+ e.preventDefault();
44
+ setError(null);
45
+
46
+ if (!name.trim()) {
47
+ setError("A name is required.");
48
+ return;
49
+ }
50
+
51
+ setSaving(true);
52
+ try {
53
+ const created = await createTransportRule({ name: name.trim(), ...rule });
54
+ window.location.href = `/admin/transport-rules/${encodeURIComponent(created.uid)}`;
55
+ } catch (err) {
56
+ setError(err instanceof ApiRequestError ? err.message : "Could not create the transport rule.");
57
+ } finally {
58
+ setSaving(false);
59
+ }
60
+ }
61
+
62
+ return (
63
+ <div className="max-w-3xl">
64
+ <h1 className="text-xl font-bold uppercase tracking-wide mb-1">New transport rule</h1>
65
+ <p className="text-sm text-text-muted mb-5">
66
+ Evaluated once per SMTP transaction, before any message is resolved to an individual mailbox.
67
+ </p>
68
+
69
+ {error && <Alert>{error}</Alert>}
70
+
71
+ <form onSubmit={handleSubmit} className="flex flex-col gap-5">
72
+ <div className="bg-surface border border-border rounded-md p-6">
73
+ <FormField label="Name" htmlFor="name">
74
+ <input
75
+ id="name"
76
+ type="text"
77
+ className={INPUT_CLASS}
78
+ value={name}
79
+ onChange={(e) => setName(e.target.value)}
80
+ placeholder="Flag external senders"
81
+ />
82
+ </FormField>
83
+ </div>
84
+
85
+ <RuleBuilder
86
+ value={rule}
87
+ onChange={setRule}
88
+ conditionFields={TRANSPORT_RULE_CONDITION_FIELDS}
89
+ actionTypes={TRANSPORT_RULE_ACTION_TYPES}
90
+ />
91
+
92
+ <div className="flex gap-3">
93
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
94
+ Create transport rule
95
+ </Button>
96
+ <a href="/admin/transport-rules">
97
+ <Button type="button" variant="secondary" className="!w-auto">
98
+ Cancel
99
+ </Button>
100
+ </a>
101
+ </div>
102
+ </form>
103
+ </div>
104
+ );
105
+ }