Add bounce mailbox processing

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent 2721ee6542
commit fe309dbff5
13 changed files with 1770 additions and 7 deletions
+216
View File
@@ -0,0 +1,216 @@
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
import {
Button,
Card,
DataGrid,
Dialog,
DismissibleAlert,
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";
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]
);
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), onClick: () => void scan(source) },
{ id: "delete", label: "Remove watcher", icon: <Trash2 aria-hidden="true" />, variant: "danger", disabled: Boolean(busy), 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>
<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} aria-hidden="true" /> Reload</Button>
<Button variant="primary" onClick={() => setAddOpen(true)} disabled={loading}><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)}>Cancel</Button><Button variant="primary" onClick={() => void addSource()} disabled={Boolean(busy) || !profileId || !folder.trim()}>Add watcher</Button></>}>
<div className="form-grid">
<FormField label="Mail profile">
<select value={profileId} onChange={(event) => setProfileId(event.target.value)}>
<option value="">Select an IMAP profile</option>
{profiles.filter((profile) => profile.imap).map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</FormField>
<FormField label="Bounce folder">
<input value={folder} onChange={(event) => setFolder(event.target.value)} placeholder="INBOX" />
</FormField>
<ToggleSwitch checked={active} onChange={setActive} label="Watch automatically" />
</div>
</Dialog>
<Dialog open={deleteSource !== null} title="Remove bounce mailbox watcher" onClose={() => !busy && setDeleteSource(null)} footer={<><Button onClick={() => setDeleteSource(null)} disabled={Boolean(busy)}>Cancel</Button><Button variant="danger" onClick={() => void removeSource()} disabled={Boolean(busy)}>Remove</Button></>}>
<p>The watcher will stop scanning this folder. Existing bounce observations and delivery evidence remain available.</p>
</Dialog>
</PageScrollViewport>
);
}
+12 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import { Activity, ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import {
Button,
DataGridPaginationBar,
@@ -8,9 +8,12 @@ import {
IconButton,
LoadingIndicator,
MessageDisplayPanel,
hasAnyScope,
formatDateTime,
i18nMessage,
type ApiSettings
useGuardedNavigate,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
bootstrapMailbox,
@@ -24,7 +27,8 @@ import {
"../../api/mail";
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const navigate = useGuardedNavigate();
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState("");
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
@@ -393,6 +397,11 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
</label>
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</span>
<div className="mailbox-toolbar-actions">
{hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) &&
<Button onClick={() => navigate("/mail/bounces")} title="Open bounce processing">
<Activity size={16} aria-hidden="true" />
Bounce status
</Button>}
<Button onClick={() => void loadProfiles()} disabled={loadingProfiles} title="i18n:govoplan-mail.reload_imap_profiles.b04c11c8">
<RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.profiles.0c2a9300