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
+66
View File
@@ -139,6 +139,72 @@ export type MailSettingsDeltaResponse = {
full: boolean;
};
export type MailBounceSource = {
id: string;
tenant_id: string;
profile_id: string;
folder: string;
imap_server_id?: string | null;
imap_credential_id?: string | null;
expected_imap_transport_revision: string;
is_active: boolean;
uidvalidity?: string | null;
highest_processed_uid: number;
last_scanned_at?: string | null;
last_success_at?: string | null;
last_error?: string | null;
};
export type MailBounceObservation = {
id: string;
profile_id: string;
folder: string;
uid: string;
original_message_id?: string | null;
command_id?: string | null;
recipient?: string | null;
action: string;
status_code?: string | null;
diagnostic?: string | null;
permanent: boolean;
observed_at: string;
matched: boolean;
evidence: Record<string, unknown>;
};
export type MailBounceSourcePayload = {
profile_id: string;
folder: string;
imap_server_id?: string | null;
imap_credential_id?: string | null;
is_active: boolean;
};
export async function listMailBounceSources(settings: ApiSettings): Promise<MailBounceSource[]> {
const response = await apiFetch<{ sources: MailBounceSource[] }>(settings, "/api/v1/mail/bounce-sources");
return response.sources;
}
export async function saveMailBounceSource(settings: ApiSettings, payload: MailBounceSourcePayload): Promise<MailBounceSource> {
return apiFetch<MailBounceSource>(settings, "/api/v1/mail/bounce-sources", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function removeMailBounceSource(settings: ApiSettings, sourceId: string): Promise<void> {
await apiFetch<void>(settings, `/api/v1/mail/bounce-sources/${encodeURIComponent(sourceId)}`, { method: "DELETE" });
}
export async function scanMailBounceSource(settings: ApiSettings, sourceId: string): Promise<{ sources: number; processed_messages: number; observations: number; failures: Array<Record<string, string>> }> {
return apiFetch(settings, `/api/v1/mail/bounce-sources/${encodeURIComponent(sourceId)}/scan`, { method: "POST" });
}
export async function listMailBounceObservations(settings: ApiSettings, limit = 100): Promise<MailBounceObservation[]> {
const response = await apiFetch<{ observations: MailBounceObservation[] }>(settings, apiPath("/api/v1/mail/bounce-observations", { limit }));
return response.observations;
}
export async function lookupMailAddresses(settings: ApiSettings, query: string, limit = 25): Promise<MailAddressLookupResponse> {
return apiFetch<MailAddressLookupResponse>(settings, apiPath("/api/v1/mail/address-lookup", { query, limit }));
}
+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
+5 -2
View File
@@ -7,7 +7,9 @@ import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/mail-profiles.css";
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
const MailBouncePage = lazy(() => import("./features/mail/MailBouncePage"));
const mailboxRead = ["mail:mailbox:read"];
const bounceRead = ["mail:bounce:read", "mail:bounce:manage"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
@@ -18,7 +20,7 @@ export const mailModule: PlatformWebModule = {
label: "i18n:govoplan-mail.mail.92379cbb",
version: "1.0.0",
dependencies: ["access"],
optionalDependencies: ["addresses"],
optionalDependencies: ["addresses", "audit", "notifications"],
translations,
viewSurfaces: [
{ id: "mail.admin.system-servers", moduleId: "mail", kind: "section", label: "System mail servers", order: 70 },
@@ -29,7 +31,8 @@ export const mailModule: PlatformWebModule = {
],
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
routes: [
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }],
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings, auth }) => createElement(MailboxPage, { settings, auth }) },
{ path: "/mail/bounces", anyOf: bounceRead, order: 51, render: ({ settings }) => createElement(MailBouncePage, { settings }) }],
uiCapabilities: {
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,