260 lines
12 KiB
TypeScript
260 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
|
|
import {
|
|
ActionBlockerHint,
|
|
Button,
|
|
Card,
|
|
ConfirmDialog,
|
|
DataGrid,
|
|
Dialog,
|
|
DismissibleAlert,
|
|
DocumentationHelpLink,
|
|
FormField,
|
|
LoadingFrame,
|
|
PageScrollViewport,
|
|
PageTitle,
|
|
StatusBadge,
|
|
TableActionGroup,
|
|
ToggleSwitch,
|
|
adminErrorMessage,
|
|
formatDateTime,
|
|
useGuardedNavigate,
|
|
type ApiSettings,
|
|
type DataGridColumn
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
listMailBounceObservations,
|
|
listMailBounceSources,
|
|
listMailServerProfiles,
|
|
removeMailBounceSource,
|
|
saveMailBounceSource,
|
|
scanMailBounceSource,
|
|
type MailBounceObservation,
|
|
type MailBounceSource,
|
|
type MailServerProfile
|
|
} from "../../api/mail";
|
|
|
|
const MAIL_BOUNCE_DOCUMENTATION = {
|
|
topicId: "mail.bounce-processing",
|
|
documentationType: "admin"
|
|
} as const;
|
|
|
|
export default function MailBouncePage({ settings }: { settings: ApiSettings }) {
|
|
const navigate = useGuardedNavigate();
|
|
const [sources, setSources] = useState<MailBounceSource[]>([]);
|
|
const [observations, setObservations] = useState<MailBounceObservation[]>([]);
|
|
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState("");
|
|
const [error, setError] = useState("");
|
|
const [message, setMessage] = useState("");
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const [deleteSource, setDeleteSource] = useState<MailBounceSource | null>(null);
|
|
const [profileId, setProfileId] = useState("");
|
|
const [folder, setFolder] = useState("INBOX");
|
|
const [active, setActive] = useState(true);
|
|
|
|
const profileNames = useMemo(
|
|
() => new Map(profiles.map((profile) => [profile.id, profile.name])),
|
|
[profiles]
|
|
);
|
|
const imapProfiles = useMemo(
|
|
() => profiles.filter((profile) => profile.is_active && profile.imap),
|
|
[profiles]
|
|
);
|
|
const pageMutationBlocker = loading
|
|
? "Bounce evidence is already loading."
|
|
: busy
|
|
? "Wait for the current bounce-processing action to finish."
|
|
: "";
|
|
const addWatcherBlocker = pageMutationBlocker
|
|
|| (imapProfiles.length === 0 ? "Configure an active IMAP-enabled Mail profile before adding a watcher." : "");
|
|
const saveWatcherBlocker = busy
|
|
? "Wait for the current bounce-processing action to finish."
|
|
: !profileId
|
|
? "Select an active IMAP-enabled Mail profile."
|
|
: !folder.trim()
|
|
? "Enter the mailbox folder that contains delivery-status messages."
|
|
: "";
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const [nextSources, nextObservations, nextProfiles] = await Promise.all([
|
|
listMailBounceSources(settings),
|
|
listMailBounceObservations(settings),
|
|
listMailServerProfiles(settings, true)
|
|
]);
|
|
setSources(nextSources);
|
|
setObservations(nextObservations);
|
|
setProfiles(nextProfiles);
|
|
setProfileId((current) => current || nextProfiles.find((profile) => profile.imap)?.id || "");
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
|
|
|
async function addSource() {
|
|
if (!profileId || !folder.trim()) return;
|
|
setBusy("add");
|
|
setError("");
|
|
try {
|
|
await saveMailBounceSource(settings, {
|
|
profile_id: profileId,
|
|
folder: folder.trim(),
|
|
is_active: active
|
|
});
|
|
setAddOpen(false);
|
|
setMessage("Bounce mailbox watcher saved.");
|
|
await load();
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function scan(source: MailBounceSource) {
|
|
setBusy(`scan:${source.id}`);
|
|
setError("");
|
|
try {
|
|
const result = await scanMailBounceSource(settings, source.id);
|
|
setMessage(`Processed ${result.processed_messages} message(s) and recorded ${result.observations} bounce observation(s).`);
|
|
await load();
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function removeSource() {
|
|
if (!deleteSource) return;
|
|
setBusy(`delete:${deleteSource.id}`);
|
|
setError("");
|
|
try {
|
|
await removeMailBounceSource(settings, deleteSource.id);
|
|
setDeleteSource(null);
|
|
setMessage("Bounce mailbox watcher removed. Existing observations were retained.");
|
|
await load();
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
const sourceColumns: DataGridColumn<MailBounceSource>[] = [
|
|
{
|
|
id: "profile",
|
|
header: "Mail profile",
|
|
width: "minmax(180px, 1fr)",
|
|
value: (source) => profileNames.get(source.profile_id) || source.profile_id,
|
|
render: (source) => <strong>{profileNames.get(source.profile_id) || source.profile_id}</strong>
|
|
},
|
|
{ id: "folder", header: "Folder", width: "minmax(150px, .8fr)", value: (source) => source.folder },
|
|
{
|
|
id: "status",
|
|
header: "Status",
|
|
width: 130,
|
|
value: (source) => source.last_error ? "error" : source.is_active ? "active" : "inactive",
|
|
render: (source) => <StatusBadge status={source.last_error ? "error" : source.is_active ? "success" : "inactive"} label={source.last_error ? "error" : source.is_active ? "active" : "inactive"} />
|
|
},
|
|
{ id: "cursor", header: "Last UID", width: 110, value: (source) => source.highest_processed_uid },
|
|
{
|
|
id: "lastScan",
|
|
header: "Last scan",
|
|
width: "minmax(180px, .8fr)",
|
|
value: (source) => source.last_scanned_at || "",
|
|
render: (source) => source.last_scanned_at ? formatDateTime(source.last_scanned_at) : "Never"
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "Actions",
|
|
width: 92,
|
|
sticky: "end",
|
|
render: (source) => <TableActionGroup actions={[
|
|
{ id: "scan", label: "Scan now", icon: <RotateCw aria-hidden="true" />, disabled: Boolean(busy), disabledReason: busy === `scan:${source.id}` ? "This mailbox scan is already running." : busy ? "Wait for the current bounce-processing action to finish." : "", onClick: () => void scan(source) },
|
|
{ id: "delete", label: "Remove watcher", icon: <Trash2 aria-hidden="true" />, variant: "danger", disabled: Boolean(busy), disabledReason: busy ? "Wait for the current bounce-processing action to finish." : "", onClick: () => setDeleteSource(source) }
|
|
]} />
|
|
}
|
|
];
|
|
|
|
const observationColumns: DataGridColumn<MailBounceObservation>[] = [
|
|
{ id: "observed", header: "Observed", width: "minmax(170px, .8fr)", value: (item) => item.observed_at, render: (item) => formatDateTime(item.observed_at) },
|
|
{ id: "recipient", header: "Recipient", width: "minmax(210px, 1fr)", filterable: true, value: (item) => item.recipient || "Unknown", render: (item) => item.recipient || <span className="muted">Unknown</span> },
|
|
{ id: "action", header: "Outcome", width: 130, filterable: true, value: (item) => `${item.action} ${item.status_code || ""}`, render: (item) => <StatusBadge status={item.permanent ? "error" : "warning"} label={item.status_code || item.action} /> },
|
|
{ id: "diagnostic", header: "Diagnostic", width: "minmax(260px, 1.4fr)", filterable: true, value: (item) => item.diagnostic || "", render: (item) => item.diagnostic || <span className="muted">No diagnostic</span> },
|
|
{ id: "correlation", header: "Correlation", width: "minmax(180px, .8fr)", value: (item) => item.command_id || item.original_message_id || "", render: (item) => item.matched ? item.command_id || item.original_message_id : <span className="muted">Unmatched</span> }
|
|
];
|
|
|
|
return (
|
|
<PageScrollViewport>
|
|
<div className="content-pad workspace-data-page">
|
|
<div className="page-heading split workspace-heading">
|
|
<div><PageTitle loading={loading}>Bounce processing</PageTitle><p>Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands.</p></div>
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>
|
|
<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />
|
|
<Button onClick={() => void load()} disabled={Boolean(pageMutationBlocker)} disabledReason={pageMutationBlocker}><RefreshCw size={16} aria-hidden="true" /> Reload</Button>
|
|
<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>
|
|
</div>
|
|
</div>
|
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
|
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
|
<LoadingFrame loading={loading} label="Loading bounce processing">
|
|
<div className="dashboard-grid">
|
|
<Card title="Watched mailboxes">
|
|
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
|
|
</Card>
|
|
<Card title="Delivery-status observations">
|
|
<DataGrid id="mail-bounce-observations" rows={observations} columns={observationColumns} getRowKey={(item) => item.id} emptyText="No bounce observations recorded." />
|
|
</Card>
|
|
</div>
|
|
</LoadingFrame>
|
|
</div>
|
|
|
|
<Dialog open={addOpen} title="Add bounce mailbox watcher" onClose={() => !busy && setAddOpen(false)} footer={<><Button onClick={() => setAddOpen(false)} disabled={Boolean(busy)} disabledReason={busy ? "Wait for the current bounce-processing action to finish." : undefined}>Cancel</Button><Button variant="primary" onClick={() => void addSource()} disabled={Boolean(saveWatcherBlocker)} disabledReason={saveWatcherBlocker}>Add watcher</Button></>}>
|
|
<div className="form-grid">
|
|
{imapProfiles.length === 0 &&
|
|
<ActionBlockerHint
|
|
reason={{
|
|
summary: "No active IMAP-enabled Mail profile can be watched.",
|
|
details: "Bounce processing reads a bounded authorized mailbox folder and cannot run without IMAP configuration.",
|
|
requiredAction: "Create or activate an IMAP server and credential first.",
|
|
actor: "Mail profile administrator",
|
|
target: "Settings or Administration > Mail profiles"
|
|
}}
|
|
documentation={MAIL_BOUNCE_DOCUMENTATION} />
|
|
}
|
|
<FormField label="Mail profile" documentation={MAIL_BOUNCE_DOCUMENTATION}>
|
|
<select value={profileId} disabled={Boolean(busy)} onChange={(event) => setProfileId(event.target.value)}>
|
|
<option value="">Select an IMAP profile</option>
|
|
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Bounce folder" documentation={MAIL_BOUNCE_DOCUMENTATION}>
|
|
<input value={folder} disabled={Boolean(busy)} onChange={(event) => setFolder(event.target.value)} placeholder="INBOX" />
|
|
</FormField>
|
|
<ToggleSwitch checked={active} disabled={Boolean(busy)} onChange={setActive} label="Watch automatically" />
|
|
</div>
|
|
</Dialog>
|
|
|
|
<ConfirmDialog
|
|
open={deleteSource !== null}
|
|
title="Remove bounce mailbox watcher"
|
|
message="The watcher will stop scanning this folder. Existing bounce observations and delivery evidence remain available."
|
|
confirmLabel="Remove watcher"
|
|
tone="danger"
|
|
busy={Boolean(busy)}
|
|
onCancel={() => setDeleteSource(null)}
|
|
onConfirm={() => void removeSource()} />
|
|
</PageScrollViewport>
|
|
);
|
|
}
|