Add bounce mailbox processing
This commit is contained in:
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user