@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,155 @@
|
|
|
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 { Folder, listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import {
|
|
9
|
+
MailFilterAction,
|
|
10
|
+
MailFilterConditions,
|
|
11
|
+
MailFilterRule,
|
|
12
|
+
getMailFilterRule,
|
|
13
|
+
updateMailFilterRule,
|
|
14
|
+
} from "@rapidmx/react-shared/mail/mailFilterRulesApi.js";
|
|
15
|
+
import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
16
|
+
import RuleBuilder, { RuleBuilderValue } from "../../../shared/components/rules/RuleBuilder.js";
|
|
17
|
+
import { MAIL_FILTER_CONDITION_FIELDS, buildMailFilterActionTypes } from "./_mailFilterRuleConfig.js";
|
|
18
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
19
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
20
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
21
|
+
|
|
22
|
+
const INPUT_CLASS =
|
|
23
|
+
"w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
24
|
+
|
|
25
|
+
export type MailFilterDetailPageProps = Omit<SettingsShellProps, "active"> & { params: { uid: string } };
|
|
26
|
+
|
|
27
|
+
export default function MailFilterDetailPage(props: MailFilterDetailPageProps) {
|
|
28
|
+
return (
|
|
29
|
+
<SettingsShell {...props} active="filters">
|
|
30
|
+
<MailFilterDetailContent uid={props.params.uid} />
|
|
31
|
+
</SettingsShell>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function MailFilterDetailContent({ uid }: { uid: string }) {
|
|
36
|
+
const { mailboxUid } = useSettingsShell();
|
|
37
|
+
const [original, setOriginal] = useState<MailFilterRule | null>(null);
|
|
38
|
+
const [name, setName] = useState("");
|
|
39
|
+
const [rule, setRule] = useState<RuleBuilderValue<MailFilterConditions, MailFilterAction> | null>(null);
|
|
40
|
+
const [folders, setFolders] = useState<Folder[] | null>(null);
|
|
41
|
+
const [loading, setLoading] = useState(true);
|
|
42
|
+
const [error, setError] = useState<string | null>(null);
|
|
43
|
+
const [saving, setSaving] = useState(false);
|
|
44
|
+
const [saved, setSaved] = useState(false);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
// `SettingsShell` only ever renders its children once `mailboxUid` has resolved — same
|
|
48
|
+
// established non-null pattern as `apps/www/settings/auto-reply/index.tsx`.
|
|
49
|
+
listFolders(mailboxUid!).then(setFolders).catch(() => setFolders([]));
|
|
50
|
+
}, [mailboxUid]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
setLoading(true);
|
|
54
|
+
setError(null);
|
|
55
|
+
getMailFilterRule(uid)
|
|
56
|
+
.then((loaded) => {
|
|
57
|
+
setOriginal(loaded);
|
|
58
|
+
if (loaded) {
|
|
59
|
+
setName(loaded.name);
|
|
60
|
+
setRule({
|
|
61
|
+
enabled: loaded.enabled,
|
|
62
|
+
sequence: loaded.sequence,
|
|
63
|
+
stopProcessingRules: loaded.stopProcessingRules,
|
|
64
|
+
conditions: loaded.conditions,
|
|
65
|
+
actions: loaded.actions,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this mail filter."))
|
|
70
|
+
.finally(() => setLoading(false));
|
|
71
|
+
}, [uid]);
|
|
72
|
+
|
|
73
|
+
// Only ever invoked from the form below, which itself only renders once `original`/`rule` are
|
|
74
|
+
// loaded (the early returns above cover every other state) — the non-null assertions reflect that
|
|
75
|
+
// real invariant, matching `TransportRuleDetailContent.handleSubmit`'s identical pattern.
|
|
76
|
+
async function handleSubmit(e: FormEvent) {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
setError(null);
|
|
79
|
+
|
|
80
|
+
if (!name.trim()) {
|
|
81
|
+
setError("A name is required.");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setSaving(true);
|
|
86
|
+
setSaved(false);
|
|
87
|
+
try {
|
|
88
|
+
const updated = await updateMailFilterRule({
|
|
89
|
+
uid: original!.uid,
|
|
90
|
+
version: original!.version,
|
|
91
|
+
name: name.trim(),
|
|
92
|
+
...rule!,
|
|
93
|
+
});
|
|
94
|
+
setOriginal(updated);
|
|
95
|
+
setSaved(true);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save this mail filter.");
|
|
98
|
+
} finally {
|
|
99
|
+
setSaving(false);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (loading || folders === null) {
|
|
104
|
+
return <p className="p-6 text-sm text-text-muted">Loading…</p>;
|
|
105
|
+
}
|
|
106
|
+
if (error && !original) {
|
|
107
|
+
return (
|
|
108
|
+
<div className="p-6">
|
|
109
|
+
<Alert>{error}</Alert>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (!original || !rule) {
|
|
114
|
+
return (
|
|
115
|
+
<div className="p-6">
|
|
116
|
+
<Alert>Mail filter not found.</Alert>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex-1 min-w-0 overflow-y-auto p-6">
|
|
123
|
+
<div className="max-w-3xl">
|
|
124
|
+
<a href={`/settings/filters?mailboxUid=${encodeURIComponent(mailboxUid!)}`} className="text-sm text-primary-dark hover:underline">
|
|
125
|
+
← All mail filters
|
|
126
|
+
</a>
|
|
127
|
+
<h1 className="text-lg font-bold tracking-tight mt-1 mb-5">{original.name}</h1>
|
|
128
|
+
|
|
129
|
+
{error && <Alert>{error}</Alert>}
|
|
130
|
+
{saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
|
|
131
|
+
|
|
132
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
|
133
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
134
|
+
<FormField label="Name" htmlFor="name">
|
|
135
|
+
<input id="name" type="text" className={INPUT_CLASS} value={name} onChange={(e) => setName(e.target.value)} />
|
|
136
|
+
</FormField>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<RuleBuilder
|
|
140
|
+
value={rule}
|
|
141
|
+
onChange={setRule}
|
|
142
|
+
conditionFields={MAIL_FILTER_CONDITION_FIELDS}
|
|
143
|
+
actionTypes={buildMailFilterActionTypes(folders)}
|
|
144
|
+
/>
|
|
145
|
+
|
|
146
|
+
<div>
|
|
147
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
148
|
+
Save changes
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
</form>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { Folder, FolderType } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
import { MailFilterAction } from "@rapidmx/react-shared/mail/mailFilterRulesApi.js";
|
|
8
|
+
import { ActionTypeDef, ConditionFieldDef } from "../../../shared/components/rules/RuleBuilder.js";
|
|
9
|
+
|
|
10
|
+
const FIELD_INPUT_CLASS =
|
|
11
|
+
"w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
12
|
+
|
|
13
|
+
/** Folder types a mail filter can't meaningfully move/copy a message into — a mailbox's single
|
|
14
|
+
* well-known `calendar`/`contacts`/`tasks`/`notes` folder holds a different entity type entirely, not
|
|
15
|
+
* messages. */
|
|
16
|
+
const NON_MAIL_FOLDER_TYPES = new Set<FolderType>(["calendar", "contacts", "tasks", "notes"]);
|
|
17
|
+
|
|
18
|
+
export const MAIL_FILTER_CONDITION_FIELDS: ConditionFieldDef[] = [
|
|
19
|
+
{ key: "fromContains", label: "From contains", kind: "list" },
|
|
20
|
+
{ key: "subjectContains", label: "Subject contains", kind: "list" },
|
|
21
|
+
{ key: "bodyContains", label: "Body contains", kind: "list" },
|
|
22
|
+
{ key: "toCcContains", label: "Any To/Cc recipient equals", kind: "list" },
|
|
23
|
+
{ key: "hasAttachment", label: "Has an attachment", kind: "boolean" },
|
|
24
|
+
{
|
|
25
|
+
key: "importance",
|
|
26
|
+
label: "Importance",
|
|
27
|
+
kind: "select",
|
|
28
|
+
options: [
|
|
29
|
+
{ value: "low", label: "Low" },
|
|
30
|
+
{ value: "normal", label: "Normal" },
|
|
31
|
+
{ value: "high", label: "High" },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Builds the mail-filter action-type registry for `RuleBuilder`, closing over `folders` so the
|
|
38
|
+
* move/copy-to-folder actions can render a real folder picker. `RuleBuilder`'s own `render()` contract
|
|
39
|
+
* is synchronous, so this resolves `folders` once up front (the caller already loads them for the page
|
|
40
|
+
* itself) rather than each action row fetching independently.
|
|
41
|
+
*/
|
|
42
|
+
export function buildMailFilterActionTypes(folders: Folder[]): ActionTypeDef<MailFilterAction>[] {
|
|
43
|
+
const destinationFolders = folders.filter((f) => !NON_MAIL_FOLDER_TYPES.has(f.type));
|
|
44
|
+
|
|
45
|
+
const folderSelect = (action: MailFilterAction, onChange: (next: MailFilterAction) => void) => (
|
|
46
|
+
<select
|
|
47
|
+
aria-label="Destination folder"
|
|
48
|
+
className={FIELD_INPUT_CLASS}
|
|
49
|
+
value={action.folderUid ?? ""}
|
|
50
|
+
onChange={(e) => onChange({ ...action, folderUid: e.target.value })}
|
|
51
|
+
>
|
|
52
|
+
<option value="">Choose a folder…</option>
|
|
53
|
+
{destinationFolders.map((folder) => (
|
|
54
|
+
<option key={folder.uid} value={folder.uid}>
|
|
55
|
+
{folder.name}
|
|
56
|
+
</option>
|
|
57
|
+
))}
|
|
58
|
+
</select>
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
value: "move_to_folder",
|
|
64
|
+
label: "Move to folder",
|
|
65
|
+
createDefault: () => ({ type: "move_to_folder", folderUid: "" }),
|
|
66
|
+
render: folderSelect,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
value: "copy_to_folder",
|
|
70
|
+
label: "Copy to folder",
|
|
71
|
+
createDefault: () => ({ type: "copy_to_folder", folderUid: "" }),
|
|
72
|
+
render: folderSelect,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
value: "delete",
|
|
76
|
+
label: "Delete the message",
|
|
77
|
+
createDefault: () => ({ type: "delete" }),
|
|
78
|
+
render: () => null,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
value: "mark_as_read",
|
|
82
|
+
label: "Mark as read",
|
|
83
|
+
createDefault: () => ({ type: "mark_as_read" }),
|
|
84
|
+
render: () => null,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
value: "forward",
|
|
88
|
+
label: "Forward to",
|
|
89
|
+
createDefault: () => ({ type: "forward", forwardTo: "" }),
|
|
90
|
+
render: (action, onChange) => (
|
|
91
|
+
<input
|
|
92
|
+
type="email"
|
|
93
|
+
aria-label="Forward to address"
|
|
94
|
+
className={FIELD_INPUT_CLASS}
|
|
95
|
+
placeholder="assistant@example.com"
|
|
96
|
+
value={action.forwardTo ?? ""}
|
|
97
|
+
onChange={(e) => onChange({ ...action, forwardTo: e.target.value })}
|
|
98
|
+
/>
|
|
99
|
+
),
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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 { MailFilterRule, listMailFilterRules } from "@rapidmx/react-shared/mail/mailFilterRulesApi.js";
|
|
8
|
+
import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.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
|
+
export type SettingsFiltersPageProps = Omit<SettingsShellProps, "active">;
|
|
13
|
+
|
|
14
|
+
export default function SettingsFiltersPage(props: SettingsFiltersPageProps) {
|
|
15
|
+
return (
|
|
16
|
+
<SettingsShell {...props} active="filters">
|
|
17
|
+
<FiltersContent />
|
|
18
|
+
</SettingsShell>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function FiltersContent() {
|
|
23
|
+
const { mailboxUid } = useSettingsShell();
|
|
24
|
+
const [rules, setRules] = useState<MailFilterRule[]>([]);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
|
|
28
|
+
// `SettingsShell` only ever renders its children once `mailboxUid` has resolved — same established
|
|
29
|
+
// non-null pattern as `apps/www/settings/auto-reply/index.tsx`.
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
listMailFilterRules(mailboxUid!)
|
|
32
|
+
.then(setRules)
|
|
33
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load mail filters."))
|
|
34
|
+
.finally(() => setLoading(false));
|
|
35
|
+
}, [mailboxUid]);
|
|
36
|
+
|
|
37
|
+
const sorted = [...rules].sort((a, b) => a.sequence - b.sequence);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex-1 min-w-0 overflow-y-auto p-6">
|
|
41
|
+
<div className="max-w-3xl">
|
|
42
|
+
<div className="flex items-center justify-between mb-5">
|
|
43
|
+
<h1 className="text-lg font-bold tracking-tight">Mail Filters</h1>
|
|
44
|
+
<a href={`/settings/filters/new?mailboxUid=${encodeURIComponent(mailboxUid!)}`}>
|
|
45
|
+
<Button type="button" className="!w-auto">
|
|
46
|
+
+ New filter
|
|
47
|
+
</Button>
|
|
48
|
+
</a>
|
|
49
|
+
</div>
|
|
50
|
+
<p className="text-sm text-text-muted mb-4">
|
|
51
|
+
Applied in order once a message is verdicted safe to deliver, before it reaches your Inbox.
|
|
52
|
+
</p>
|
|
53
|
+
|
|
54
|
+
{error && <Alert>{error}</Alert>}
|
|
55
|
+
|
|
56
|
+
{loading ? (
|
|
57
|
+
<p className="text-sm text-text-muted">Loading…</p>
|
|
58
|
+
) : sorted.length === 0 ? (
|
|
59
|
+
<p className="text-sm text-text-muted">No mail filters yet.</p>
|
|
60
|
+
) : (
|
|
61
|
+
<div className="overflow-x-auto">
|
|
62
|
+
<table className="w-full text-sm border-collapse">
|
|
63
|
+
<thead>
|
|
64
|
+
<tr>
|
|
65
|
+
{["Sequence", "Name", "Enabled", "Actions", ""].map((h) => (
|
|
66
|
+
<th
|
|
67
|
+
key={h}
|
|
68
|
+
className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
|
|
69
|
+
>
|
|
70
|
+
{h}
|
|
71
|
+
</th>
|
|
72
|
+
))}
|
|
73
|
+
</tr>
|
|
74
|
+
</thead>
|
|
75
|
+
<tbody>
|
|
76
|
+
{sorted.map((rule) => (
|
|
77
|
+
<tr key={rule.uid}>
|
|
78
|
+
<td className="py-2.5 px-2.5 border-b border-border">{rule.sequence}</td>
|
|
79
|
+
<td className="py-2.5 px-2.5 border-b border-border">{rule.name}</td>
|
|
80
|
+
<td className="py-2.5 px-2.5 border-b border-border">{rule.enabled ? "Yes" : "No"}</td>
|
|
81
|
+
<td className="py-2.5 px-2.5 border-b border-border">{rule.actions.length}</td>
|
|
82
|
+
<td className="py-2.5 px-2.5 border-b border-border text-right">
|
|
83
|
+
<a
|
|
84
|
+
href={`/settings/filters/${encodeURIComponent(rule.uid)}?mailboxUid=${encodeURIComponent(mailboxUid!)}`}
|
|
85
|
+
className="text-primary-dark hover:underline font-medium"
|
|
86
|
+
>
|
|
87
|
+
View
|
|
88
|
+
</a>
|
|
89
|
+
</td>
|
|
90
|
+
</tr>
|
|
91
|
+
))}
|
|
92
|
+
</tbody>
|
|
93
|
+
</table>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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 { Folder, listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import {
|
|
9
|
+
MailFilterAction,
|
|
10
|
+
MailFilterConditions,
|
|
11
|
+
createMailFilterRule,
|
|
12
|
+
} from "@rapidmx/react-shared/mail/mailFilterRulesApi.js";
|
|
13
|
+
import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../../shared/components/settings/layout/SettingsShell.js";
|
|
14
|
+
import RuleBuilder, { RuleBuilderValue } from "../../../../shared/components/rules/RuleBuilder.js";
|
|
15
|
+
import { MAIL_FILTER_CONDITION_FIELDS, buildMailFilterActionTypes } from "../_mailFilterRuleConfig.js";
|
|
16
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
17
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
18
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
19
|
+
|
|
20
|
+
const INPUT_CLASS =
|
|
21
|
+
"w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
22
|
+
|
|
23
|
+
export type NewMailFilterPageProps = Omit<SettingsShellProps, "active">;
|
|
24
|
+
|
|
25
|
+
export default function NewMailFilterPage(props: NewMailFilterPageProps) {
|
|
26
|
+
return (
|
|
27
|
+
<SettingsShell {...props} active="filters">
|
|
28
|
+
<NewMailFilterForm />
|
|
29
|
+
</SettingsShell>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function NewMailFilterForm() {
|
|
34
|
+
const { mailboxUid } = useSettingsShell();
|
|
35
|
+
const [folders, setFolders] = useState<Folder[] | null>(null);
|
|
36
|
+
const [folderError, setFolderError] = useState<string | null>(null);
|
|
37
|
+
const [name, setName] = useState("");
|
|
38
|
+
const [rule, setRule] = useState<RuleBuilderValue<MailFilterConditions, MailFilterAction>>({
|
|
39
|
+
enabled: true,
|
|
40
|
+
sequence: 0,
|
|
41
|
+
stopProcessingRules: false,
|
|
42
|
+
conditions: {},
|
|
43
|
+
actions: [],
|
|
44
|
+
});
|
|
45
|
+
const [error, setError] = useState<string | null>(null);
|
|
46
|
+
const [saving, setSaving] = useState(false);
|
|
47
|
+
|
|
48
|
+
// `SettingsShell` only ever renders its children once `mailboxUid` has resolved — same established
|
|
49
|
+
// non-null pattern as `apps/www/settings/auto-reply/index.tsx`.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
listFolders(mailboxUid!)
|
|
52
|
+
.then(setFolders)
|
|
53
|
+
.catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's folders."));
|
|
54
|
+
}, [mailboxUid]);
|
|
55
|
+
|
|
56
|
+
async function handleSubmit(e: FormEvent) {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
setError(null);
|
|
59
|
+
|
|
60
|
+
if (!name.trim()) {
|
|
61
|
+
setError("A name is required.");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setSaving(true);
|
|
66
|
+
try {
|
|
67
|
+
const created = await createMailFilterRule({ mailboxUid: mailboxUid!, name: name.trim(), ...rule });
|
|
68
|
+
window.location.href = `/settings/filters/${encodeURIComponent(created.uid)}?mailboxUid=${encodeURIComponent(mailboxUid!)}`;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not create the mail filter.");
|
|
71
|
+
} finally {
|
|
72
|
+
setSaving(false);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex-1 min-w-0 overflow-y-auto p-6">
|
|
78
|
+
<div className="max-w-3xl">
|
|
79
|
+
<h1 className="text-lg font-bold tracking-tight mb-1">New mail filter</h1>
|
|
80
|
+
<p className="text-sm text-text-muted mb-5">
|
|
81
|
+
Applied in order once a message is verdicted safe to deliver, before it reaches your Inbox.
|
|
82
|
+
</p>
|
|
83
|
+
|
|
84
|
+
{error && <Alert>{error}</Alert>}
|
|
85
|
+
{folderError && <Alert>{folderError}</Alert>}
|
|
86
|
+
|
|
87
|
+
{folders === null ? (
|
|
88
|
+
<p className="text-sm text-text-muted">Loading…</p>
|
|
89
|
+
) : (
|
|
90
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
|
91
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
92
|
+
<FormField label="Name" htmlFor="name">
|
|
93
|
+
<input
|
|
94
|
+
id="name"
|
|
95
|
+
type="text"
|
|
96
|
+
className={INPUT_CLASS}
|
|
97
|
+
value={name}
|
|
98
|
+
onChange={(e) => setName(e.target.value)}
|
|
99
|
+
placeholder="File newsletters"
|
|
100
|
+
/>
|
|
101
|
+
</FormField>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<RuleBuilder
|
|
105
|
+
value={rule}
|
|
106
|
+
onChange={setRule}
|
|
107
|
+
conditionFields={MAIL_FILTER_CONDITION_FIELDS}
|
|
108
|
+
actionTypes={buildMailFilterActionTypes(folders)}
|
|
109
|
+
/>
|
|
110
|
+
|
|
111
|
+
<div className="flex gap-3">
|
|
112
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
113
|
+
Create filter
|
|
114
|
+
</Button>
|
|
115
|
+
<a href={`/settings/filters?mailboxUid=${encodeURIComponent(mailboxUid!)}`}>
|
|
116
|
+
<Button type="button" variant="secondary" className="!w-auto">
|
|
117
|
+
Cancel
|
|
118
|
+
</Button>
|
|
119
|
+
</a>
|
|
120
|
+
</div>
|
|
121
|
+
</form>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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 { MessageClassification } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import {
|
|
9
|
+
FocusedInboxOverride,
|
|
10
|
+
createFocusedInboxOverride,
|
|
11
|
+
deleteFocusedInboxOverride,
|
|
12
|
+
listFocusedInboxOverrides,
|
|
13
|
+
} from "@rapidmx/react-shared/mail/focusedInboxOverridesApi.js";
|
|
14
|
+
import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
15
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
16
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
17
|
+
|
|
18
|
+
export type SettingsFocusedInboxPageProps = Omit<SettingsShellProps, "active">;
|
|
19
|
+
|
|
20
|
+
export default function SettingsFocusedInboxPage(props: SettingsFocusedInboxPageProps) {
|
|
21
|
+
return (
|
|
22
|
+
<SettingsShell {...props} active="focused-inbox">
|
|
23
|
+
<FocusedInboxContent />
|
|
24
|
+
</SettingsShell>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function FocusedInboxContent() {
|
|
29
|
+
const { mailboxUid } = useSettingsShell();
|
|
30
|
+
const [overrides, setOverrides] = useState<FocusedInboxOverride[]>([]);
|
|
31
|
+
const [loading, setLoading] = useState(true);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
const [saving, setSaving] = useState(false);
|
|
34
|
+
const [newSender, setNewSender] = useState("");
|
|
35
|
+
const [newClassifyAs, setNewClassifyAs] = useState<MessageClassification>("other");
|
|
36
|
+
|
|
37
|
+
function reload() {
|
|
38
|
+
setLoading(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
// `SettingsShell` only ever renders its children once `mailboxUid` has resolved — same
|
|
41
|
+
// established non-null pattern as `apps/www/settings/filters/index.tsx`.
|
|
42
|
+
listFocusedInboxOverrides(mailboxUid!)
|
|
43
|
+
.then(setOverrides)
|
|
44
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load your Focused Inbox rules."))
|
|
45
|
+
.finally(() => setLoading(false));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
useEffect(reload, [mailboxUid]);
|
|
49
|
+
|
|
50
|
+
async function handleAdd(e: FormEvent) {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
const senderAddress = newSender.trim();
|
|
53
|
+
if (!senderAddress) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
setSaving(true);
|
|
57
|
+
setError(null);
|
|
58
|
+
try {
|
|
59
|
+
await createFocusedInboxOverride({ mailboxUid: mailboxUid!, senderAddress, classifyAs: newClassifyAs });
|
|
60
|
+
setNewSender("");
|
|
61
|
+
reload();
|
|
62
|
+
} catch (err) {
|
|
63
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not add this rule.");
|
|
64
|
+
} finally {
|
|
65
|
+
setSaving(false);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function handleRemove(override: FocusedInboxOverride) {
|
|
70
|
+
setSaving(true);
|
|
71
|
+
setError(null);
|
|
72
|
+
try {
|
|
73
|
+
await deleteFocusedInboxOverride(override.uid, override.version);
|
|
74
|
+
reload();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not remove this rule.");
|
|
77
|
+
} finally {
|
|
78
|
+
setSaving(false);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex-1 min-w-0 overflow-y-auto p-6">
|
|
84
|
+
<div className="max-w-xl">
|
|
85
|
+
<h1 className="text-lg font-bold tracking-tight mb-1">Focused Inbox</h1>
|
|
86
|
+
<p className="text-sm text-text-muted mb-4">
|
|
87
|
+
Mail from a sender listed here always goes to the matching half of your Inbox, overriding
|
|
88
|
+
the automatic Focused/Other classification. Add one directly, or use “Always for this
|
|
89
|
+
sender” when moving a message.
|
|
90
|
+
</p>
|
|
91
|
+
|
|
92
|
+
{error && <Alert>{error}</Alert>}
|
|
93
|
+
|
|
94
|
+
{loading ? (
|
|
95
|
+
<p className="text-sm text-text-muted">Loading…</p>
|
|
96
|
+
) : (
|
|
97
|
+
<ul className="flex flex-col gap-2 mb-4">
|
|
98
|
+
{overrides.length === 0 && <li className="text-sm text-text-muted">No rules yet.</li>}
|
|
99
|
+
{overrides.map((override) => (
|
|
100
|
+
<li
|
|
101
|
+
key={override.uid}
|
|
102
|
+
className="flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm"
|
|
103
|
+
>
|
|
104
|
+
<div>
|
|
105
|
+
<div className="text-sm font-medium">{override.senderAddress}</div>
|
|
106
|
+
<div className="text-xs text-text-muted">
|
|
107
|
+
Always {override.classifyAs === "other" ? "Other" : "Focused"}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={() => handleRemove(override)}
|
|
113
|
+
disabled={saving}
|
|
114
|
+
className="text-sm text-danger hover:underline disabled:opacity-55"
|
|
115
|
+
>
|
|
116
|
+
Remove
|
|
117
|
+
</button>
|
|
118
|
+
</li>
|
|
119
|
+
))}
|
|
120
|
+
</ul>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
<form onSubmit={handleAdd} className="flex gap-2">
|
|
124
|
+
<input
|
|
125
|
+
type="text"
|
|
126
|
+
aria-label="Sender address"
|
|
127
|
+
value={newSender}
|
|
128
|
+
onChange={(e) => setNewSender(e.target.value)}
|
|
129
|
+
placeholder="sender@example.com"
|
|
130
|
+
className="flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary"
|
|
131
|
+
/>
|
|
132
|
+
<select
|
|
133
|
+
aria-label="Classify as"
|
|
134
|
+
value={newClassifyAs}
|
|
135
|
+
onChange={(e) => setNewClassifyAs(e.target.value as MessageClassification)}
|
|
136
|
+
className="text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary"
|
|
137
|
+
>
|
|
138
|
+
<option value="other">Other</option>
|
|
139
|
+
<option value="focused">Focused</option>
|
|
140
|
+
</select>
|
|
141
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
142
|
+
Add
|
|
143
|
+
</Button>
|
|
144
|
+
</form>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|