@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.
- package/LICENSE +382 -0
- package/apps/admin/_404.tsx +9 -0
- package/apps/admin/_500.tsx +9 -0
- package/apps/admin/_layout.tsx +41 -0
- package/apps/admin/audit-log/index.tsx +187 -0
- package/apps/admin/branding/index.tsx +404 -0
- package/apps/admin/distribution-lists/[uid].tsx +70 -0
- package/apps/admin/distribution-lists/index.tsx +115 -0
- package/apps/admin/distribution-lists/new/index.tsx +114 -0
- package/apps/admin/domains/[uid].tsx +259 -0
- package/apps/admin/domains/index.tsx +125 -0
- package/apps/admin/domains/new/index.tsx +84 -0
- package/apps/admin/index.tsx +82 -0
- package/apps/admin/ingest-queue/index.tsx +116 -0
- package/apps/admin/mailboxes/[uid].tsx +126 -0
- package/apps/admin/mailboxes/new/index.tsx +291 -0
- package/apps/admin/quarantine/index.tsx +171 -0
- package/apps/admin/transport-rules/[uid].tsx +143 -0
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
- package/apps/admin/transport-rules/index.tsx +117 -0
- package/apps/admin/transport-rules/new/index.tsx +105 -0
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
- package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
- package/apps/shared/components/calendar/EventModal.tsx +513 -0
- package/apps/shared/components/calendar/MonthView.tsx +139 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
- package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
- package/apps/shared/components/contacts/ContactForm.tsx +314 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
- package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
- package/apps/shared/components/layout/AppShell.tsx +165 -0
- package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
- package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
- package/apps/shared/components/layout/UserMenu.tsx +150 -0
- package/apps/shared/components/mail/ConversationList.tsx +61 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
- package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
- package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
- package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
- package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
- package/apps/shared/styles/app.css +265 -0
- package/apps/www/_404.tsx +9 -0
- package/apps/www/_500.tsx +9 -0
- package/apps/www/_layout.tsx +45 -0
- package/apps/www/calendar/index.tsx +407 -0
- package/apps/www/contacts/[uid].tsx +88 -0
- package/apps/www/contacts/index.tsx +436 -0
- package/apps/www/index.tsx +375 -0
- package/apps/www/messages/[uid].tsx +70 -0
- package/apps/www/settings/auto-reply/index.tsx +129 -0
- package/apps/www/settings/booking-types/[uid].tsx +300 -0
- package/apps/www/settings/booking-types/index.tsx +98 -0
- package/apps/www/settings/booking-types/new/index.tsx +212 -0
- package/apps/www/settings/filters/[uid].tsx +155 -0
- package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
- package/apps/www/settings/filters/index.tsx +99 -0
- package/apps/www/settings/filters/new/index.tsx +126 -0
- package/apps/www/settings/focused-inbox/index.tsx +148 -0
- package/apps/www/settings/read-receipts/index.tsx +125 -0
- package/apps/www/settings/signatures/[uid].tsx +167 -0
- package/apps/www/settings/signatures/index.tsx +85 -0
- package/apps/www/settings/signatures/new/index.tsx +129 -0
- package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
- package/apps/www/tasks/index.tsx +530 -0
- package/dist/apps/admin/_404.js +4 -0
- package/dist/apps/admin/_500.js +4 -0
- package/dist/apps/admin/_layout.js +10 -0
- package/dist/apps/admin/audit-log/index.js +66 -0
- package/dist/apps/admin/branding/index.js +126 -0
- package/dist/apps/admin/distribution-lists/[uid].js +34 -0
- package/dist/apps/admin/distribution-lists/index.js +31 -0
- package/dist/apps/admin/distribution-lists/new/index.js +51 -0
- package/dist/apps/admin/domains/[uid].js +102 -0
- package/dist/apps/admin/domains/index.js +31 -0
- package/dist/apps/admin/domains/new/index.js +41 -0
- package/dist/apps/admin/index.js +32 -0
- package/dist/apps/admin/ingest-queue/index.js +48 -0
- package/dist/apps/admin/mailboxes/[uid].js +59 -0
- package/dist/apps/admin/mailboxes/new/index.js +93 -0
- package/dist/apps/admin/quarantine/index.js +66 -0
- package/dist/apps/admin/transport-rules/[uid].js +87 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
- package/dist/apps/admin/transport-rules/index.js +32 -0
- package/dist/apps/admin/transport-rules/new/index.js +50 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
- package/dist/apps/shared/components/calendar/EventModal.js +167 -0
- package/dist/apps/shared/components/calendar/MonthView.js +38 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
- package/dist/apps/shared/components/layout/AppShell.js +68 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
- package/dist/apps/shared/components/layout/UserMenu.js +59 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
- package/dist/apps/shared/styles/app.css +265 -0
- package/dist/apps/www/_404.js +4 -0
- package/dist/apps/www/_500.js +4 -0
- package/dist/apps/www/_layout.js +8 -0
- package/dist/apps/www/calendar/index.js +228 -0
- package/dist/apps/www/contacts/[uid].js +62 -0
- package/dist/apps/www/contacts/index.js +262 -0
- package/dist/apps/www/index.js +228 -0
- package/dist/apps/www/messages/[uid].js +47 -0
- package/dist/apps/www/settings/auto-reply/index.js +54 -0
- package/dist/apps/www/settings/booking-types/[uid].js +129 -0
- package/dist/apps/www/settings/booking-types/index.js +29 -0
- package/dist/apps/www/settings/booking-types/new/index.js +86 -0
- package/dist/apps/www/settings/filters/[uid].js +94 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
- package/dist/apps/www/settings/filters/index.js +30 -0
- package/dist/apps/www/settings/filters/new/index.js +61 -0
- package/dist/apps/www/settings/focused-inbox/index.js +69 -0
- package/dist/apps/www/settings/read-receipts/index.js +51 -0
- package/dist/apps/www/settings/signatures/[uid].js +96 -0
- package/dist/apps/www/settings/signatures/index.js +34 -0
- package/dist/apps/www/settings/signatures/new/index.js +63 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
- package/dist/apps/www/tasks/index.js +280 -0
- 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
|
+
← 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…</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…</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
|
+
← 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…</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
|
+
}
|