import { useEffect, useMemo, useState } from "react"; import { ArrowLeft, Pencil, Plus, ShieldCheck } from "lucide-react"; import { ActionBlockerHint, Button, Card, ConfirmDialog, ContentGrid, DataGrid, Dialog, DocumentationHelpLink, FormField, FormGrid, PageActionBar, PageLayout, SelectionList, SelectionListItem, SelectionListItemContent, StatusBadge, TableActionGroup, ToggleSwitch, adminErrorMessage, formatDateTime, hasScope, useGuardedNavigate, type ApiSettings, type AuthInfo, type DataGridColumn } from "@govoplan/core-webui"; import { createMailServerCredential, createMailServerEndpoint, importMailProfilePop3, listMailPop3Imports, listMailServerProfiles, previewMailProfilePop3, testMailProfilePop3, updateMailServerCredential, updateMailServerEndpoint, type MailCredentialEnvelope, type MailPop3ImportRecord, type MailPop3MessagePreview, type MailPop3PreviewResponse, type MailPop3ServerEndpoint, type MailServerProfile } from "../../api/mail"; const DOCUMENTATION = { topicId: "mail.workflow.legacy-pop3-import", documentationType: "admin" } as const; type Source = { profile: MailServerProfile; server: MailPop3ServerEndpoint; }; type SourceDraft = { profileId: string; name: string; host: string; port: string; security: "tls" | "starttls" | "plain"; timeoutSeconds: string; maxMessageMiB: string; maxBatchMiB: string; previewBodyLines: string; username: string; password: string; enabled: boolean; allowDeleteAfterImport: boolean; }; const EMPTY_DRAFT: SourceDraft = { profileId: "", name: "Legacy POP3 source", host: "", port: "995", security: "tls", timeoutSeconds: "30", maxMessageMiB: "25", maxBatchMiB: "100", previewBodyLines: "20", username: "", password: "", enabled: true, allowDeleteAfterImport: false }; export default function MailLegacyImportPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo; }) { const navigate = useGuardedNavigate(); const [profiles, setProfiles] = useState([]); const [imports, setImports] = useState([]); const [selectedSourceId, setSelectedSourceId] = useState(""); const [preview, setPreview] = useState(null); const [selectedUidls, setSelectedUidls] = useState([]); const [deleteAfterImport, setDeleteAfterImport] = useState(false); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(""); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [sourceDialogOpen, setSourceDialogOpen] = useState(false); const [editingSourceId, setEditingSourceId] = useState(null); const [sourceDraft, setSourceDraft] = useState(EMPTY_DRAFT); const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); const canImport = hasScope(auth, "mail:pop3:import"); const canDelete = hasScope(auth, "mail:pop3:delete"); const canManage = hasScope(auth, "mail:pop3:manage"); const canManageSecrets = hasScope(auth, "mail:secret:manage"); const sources = useMemo(() => pop3Sources(profiles), [profiles]); const selectedSource = sources.find((item) => item.server.id === selectedSourceId) ?? sources[0] ?? null; const selectedCredential = selectedSource ? defaultCredential(selectedSource.server) : null; const configurableProfiles = profiles.filter((profile) => profileCanBeConfigured(auth, profile)); const sourceCanBeConfigured = selectedSource ? profileCanBeConfigured(auth, selectedSource.profile) : false; async function load() { setLoading(true); setError(""); try { const [nextProfiles, nextImports] = await Promise.all([ listMailServerProfiles(settings, canManage), canImport ? listMailPop3Imports(settings, null, 200) : Promise.resolve([]) ]); const nextSources = pop3Sources(nextProfiles); setProfiles(nextProfiles); setImports(nextImports); setSelectedSourceId((current) => nextSources.some((item) => item.server.id === current) ? current : nextSources[0]?.server.id ?? "" ); } catch (reason) { setError(adminErrorMessage(reason)); } finally { setLoading(false); } } useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, canImport]); useEffect(() => { setPreview(null); setSelectedUidls([]); setDeleteAfterImport(false); }, [selectedSourceId]); async function runConnectionTest() { if (!selectedSource) return; setBusy("test"); setError(""); setSuccess(""); try { const result = await testMailProfilePop3( settings, selectedSource.profile.id, selectedSource.server.id, selectedCredential?.id ); if (!result.ok) throw new Error(result.message); setSuccess( `POP3 authentication succeeded. The mailbox currently reports ${String(result.details.message_count ?? 0)} message(s).` ); } catch (reason) { setError(adminErrorMessage(reason)); } finally { setBusy(""); } } async function refreshPreview() { if (!selectedSource || !canImport) return; setBusy("preview"); setError(""); setSuccess(""); try { const next = await previewMailProfilePop3(settings, selectedSource.profile.id, { server_id: selectedSource.server.id, credential_id: selectedCredential?.id, limit: 100 }); setPreview(next); setSelectedUidls((current) => current.filter((uidl) => next.messages.some((item) => item.uidl === uidl && !item.already_imported)) ); } catch (reason) { setError(adminErrorMessage(reason)); } finally { setBusy(""); } } async function runImport() { if (!selectedSource || !preview || selectedUidls.length === 0) return; setDeleteConfirmationOpen(false); setBusy("import"); setError(""); setSuccess(""); try { const result = await importMailProfilePop3(settings, selectedSource.profile.id, { server_id: selectedSource.server.id, credential_id: selectedCredential?.id, expected_transport_revision: preview.transport_revision, uidls: selectedUidls, delete_after_import: deleteAfterImport }); setSuccess( `${result.imports.length} message(s) imported; ${result.duplicate_uidls.length} duplicate(s) skipped. Source deletion: ${result.deletion_status.replaceAll("_", " ")}.` ); setSelectedUidls([]); const [nextPreview, nextImports] = await Promise.all([ previewMailProfilePop3(settings, selectedSource.profile.id, { server_id: selectedSource.server.id, credential_id: selectedCredential?.id, limit: 100 }), listMailPop3Imports(settings, null, 200) ]); setPreview(nextPreview); setImports(nextImports); } catch (reason) { setError(adminErrorMessage(reason)); } finally { setBusy(""); } } function openSourceDialog(source: Source | null) { const credential = source ? defaultCredential(source.server) : null; setEditingSourceId(source?.server.id ?? null); setSourceDraft(source ? sourceDraftFromSource(source, credential) : { ...EMPTY_DRAFT, profileId: selectedSource?.profile.id ?? configurableProfiles[0]?.id ?? "" }); setSourceDialogOpen(true); } async function saveSource() { const profile = profiles.find((item) => item.id === sourceDraft.profileId); if (!profile) return; const existing = sources.find((item) => item.server.id === editingSourceId) ?? null; const existingCredential = existing ? defaultCredential(existing.server) : null; setBusy("source"); setError(""); setSuccess(""); try { if (existing) { if (canManageSecrets && (sourceDraft.password || !existingCredential)) { if (existingCredential) { await updateMailServerCredential( settings, existing.profile.id, existing.server.id, existingCredential.id, { name: `${sourceDraft.name.trim()} credential`, username: sourceDraft.username.trim(), ...(sourceDraft.password ? { password: sourceDraft.password } : {}) } ); } else { await createMailServerCredential( settings, existing.profile.id, existing.server.id, credentialPayload(sourceDraft, existing.server.id) ); } } await updateMailServerEndpoint(settings, existing.profile.id, existing.server.id, { name: sourceDraft.name.trim(), config: sourceConfig(sourceDraft), is_active: true }); setSuccess("Legacy POP3 source updated."); } else { const disabledServer = await createMailServerEndpoint(settings, profile.id, { protocol: "pop3", name: sourceDraft.name.trim(), config: { ...sourceConfig(sourceDraft), legacy_import_enabled: false, allow_delete_after_import: false }, is_default: false, is_active: false }); await createMailServerCredential( settings, profile.id, disabledServer.id, credentialPayload(sourceDraft, disabledServer.id) ); await updateMailServerEndpoint(settings, profile.id, disabledServer.id, { config: sourceConfig(sourceDraft), is_active: true }); setSelectedSourceId(disabledServer.id); setSuccess("Legacy POP3 source created. It was enabled only after its encrypted credential was stored."); } setSourceDialogOpen(false); await load(); } catch (reason) { setError(adminErrorMessage(reason)); } finally { setBusy(""); } } const sourceBlocker = sourceSaveBlocker({ draft: sourceDraft, existingCredential: editingSourceId ? defaultCredential(sources.find((item) => item.server.id === editingSourceId)?.server) : null, canManageSecrets }); const importBlocker = !selectedSource ? "Select an enabled legacy POP3 source." : !preview ? "Refresh the live preview before importing." : selectedUidls.length === 0 ? "Select at least one message that has not already been imported." : deleteAfterImport && (!canDelete || !preview.delete_after_import_allowed) ? "Source deletion needs both the destructive permission and an endpoint policy that allows it." : ""; const previewColumns: DataGridColumn[] = [ { id: "select", header: "Select", width: 82, render: (item) => setSelectedUidls((current) => current.includes(item.uidl) ? current.filter((uidl) => uidl !== item.uidl) : [...current, item.uidl] )} /> }, { id: "subject", header: "Message", width: "minmax(240px, 1.3fr)", filterable: true, value: (item) => `${item.subject || ""} ${item.from_header || ""}`, render: (item) => {item.subject || "No subject"}
{item.from_header || "Unknown sender"}
}, { id: "date", header: "Provider date", width: "minmax(170px, .8fr)", value: (item) => item.date || "", render: (item) => item.date || "Unknown" }, { id: "size", header: "Size", width: 105, value: (item) => item.size_bytes, render: (item) => formatBytes(item.size_bytes) }, { id: "state", header: "State", width: 130, value: (item) => item.already_imported ? "imported" : "available", render: (item) => } ]; const importColumns: DataGridColumn[] = [ { id: "imported", header: "Imported", width: "minmax(170px, .8fr)", value: (item) => item.imported_at, render: (item) => formatDateTime(item.imported_at) }, { id: "subject", header: "Message", width: "minmax(240px, 1.2fr)", filterable: true, value: (item) => `${item.subject || ""} ${item.from_header || ""}`, render: (item) => {item.subject || "No subject"}
{item.from_header || "Unknown sender"}
}, { id: "review", header: "Review state", width: 140, value: (item) => item.status, render: (item) => }, { id: "deletion", header: "Source deletion", width: 165, value: (item) => item.deletion_status, render: (item) => } ]; return ( <> void load(), helpContextId: "mail.pop3.action.reload", helpModuleId: "mail", helpTopicId: "mail.workflow.legacy-pop3-import", disabled: Boolean(busy), disabledReason: busy ? "Wait for the current POP3 action to finish." : undefined }} contextActions={} helpAction={} createAction={canManage ? : undefined} />} loading={loading} loadingLabel="Loading legacy POP3 sources" error={error} success={success} documentationType="admin" > {sources.length === 0 ? : {sources.map((source) => setSelectedSourceId(source.server.id)}> } /> )} } {preview ? <>

Provider reports {preview.message_count} message(s), {formatBytes(preview.mailbox_size_bytes)} total. Preview and ordinary import do not delete source messages.

item.uidl} emptyText="The legacy mailbox contains no messages." /> :

Refresh a source to obtain a bounded, non-destructive preview.

}
item.id} emptyText="No legacy messages have been imported." />
!busy && setSourceDialogOpen(false)} footer={<>} > setSourceDraft((draft) => ({ ...draft, name: event.target.value }))} /> setSourceDraft((draft) => ({ ...draft, host: event.target.value }))} placeholder="pop3.example.org" /> setSourceDraft((draft) => ({ ...draft, port: event.target.value }))} /> setSourceDraft((draft) => ({ ...draft, timeoutSeconds: event.target.value }))} /> setSourceDraft((draft) => ({ ...draft, maxMessageMiB: event.target.value }))} /> setSourceDraft((draft) => ({ ...draft, maxBatchMiB: event.target.value }))} /> setSourceDraft((draft) => ({ ...draft, previewBodyLines: event.target.value }))} /> setSourceDraft((draft) => ({ ...draft, username: event.target.value }))} autoComplete="username" /> setSourceDraft((draft) => ({ ...draft, password: event.target.value }))} autoComplete="new-password" /> setSourceDraft((draft) => ({ ...draft, enabled, allowDeleteAfterImport: enabled ? draft.allowDeleteAfterImport : false }))} label="Explicitly enable legacy import" help="Off is the product default." /> setSourceDraft((draft) => ({ ...draft, allowDeleteAfterImport }))} label="Permit delete-after-import requests" help="Operators still need a separate destructive permission and must choose deletion per batch." /> setDeleteConfirmationOpen(false)} onConfirm={() => void runImport()} /> ); } function pop3Sources(profiles: MailServerProfile[]): Source[] { return profiles.flatMap((profile) => ((profile.servers ?? []) as unknown as MailPop3ServerEndpoint[]) .filter((server) => server.protocol === "pop3") .map((server) => ({ profile, server })) ); } function defaultCredential(server: MailPop3ServerEndpoint | undefined): MailCredentialEnvelope | null { if (!server) return null; return server.credentials.find((credential) => credential.is_default) ?? server.credentials.find((credential) => credential.is_active) ?? server.credentials[0] ?? null; } function profileCanBeConfigured(auth: AuthInfo, profile: MailServerProfile): boolean { if (profile.scope_type === "system") return hasScope(auth, "system:settings:write"); if (hasScope(auth, "mail:profile:write")) return true; return profile.scope_type === "user" && profile.scope_id === auth.user.id && hasScope(auth, "mail:profile:write_own"); } function sourceDraftFromSource(source: Source, credential: MailCredentialEnvelope | null): SourceDraft { const config = source.server.config; return { profileId: source.profile.id, name: source.server.name, host: String(config.host ?? ""), port: String(config.port ?? (config.security === "tls" ? 995 : 110)), security: config.security === "starttls" || config.security === "plain" ? config.security : "tls", timeoutSeconds: String(config.timeout_seconds ?? 30), maxMessageMiB: String(Math.max(1, Math.round(Number(config.max_message_bytes ?? 25 * 1024 * 1024) / 1024 / 1024))), maxBatchMiB: String(Math.max(1, Math.round(Number(config.max_batch_bytes ?? 100 * 1024 * 1024) / 1024 / 1024))), previewBodyLines: String(config.preview_body_lines ?? 20), username: String(credential?.public_data?.username ?? ""), password: "", enabled: Boolean(config.legacy_import_enabled), allowDeleteAfterImport: Boolean(config.allow_delete_after_import) }; } function sourceConfig(draft: SourceDraft): Record { return { host: draft.host.trim(), port: Number(draft.port), security: draft.security, timeout_seconds: Number(draft.timeoutSeconds), max_message_bytes: Number(draft.maxMessageMiB) * 1024 * 1024, max_batch_bytes: Number(draft.maxBatchMiB) * 1024 * 1024, preview_body_lines: Number(draft.previewBodyLines), legacy_import_enabled: draft.enabled, allow_delete_after_import: draft.allowDeleteAfterImport }; } function credentialPayload(draft: SourceDraft, serverId: string) { return { name: `${draft.name.trim()} credential`, credential_kind: "username_password", username: draft.username.trim(), password: draft.password, allowed_modules: ["mail"], allowed_server_refs: [`mail:${serverId}`], is_default: true }; } function sourceSaveBlocker({ draft, existingCredential, canManageSecrets }: { draft: SourceDraft; existingCredential: MailCredentialEnvelope | null; canManageSecrets: boolean; }): string { if (!draft.profileId) return "Select a Mail profile."; if (!draft.name.trim()) return "Enter a source name."; if (!draft.host.trim()) return "Enter the POP3 host."; if (!boundedInteger(draft.port, 1, 65535)) return "Enter a valid POP3 port."; if (!boundedInteger(draft.timeoutSeconds, 1, 300)) return "Enter a timeout from 1 to 300 seconds."; if (!boundedInteger(draft.maxMessageMiB, 1, 50)) return "Enter a message limit from 1 to 50 MiB."; if (!boundedInteger(draft.maxBatchMiB, 1, 500)) return "Enter a batch limit from 1 to 500 MiB."; if (Number(draft.maxBatchMiB) < Number(draft.maxMessageMiB)) return "The batch limit cannot be lower than the per-message limit."; if (!boundedInteger(draft.previewBodyLines, 0, 100)) return "Enter 0 to 100 preview body lines."; if (draft.allowDeleteAfterImport && !draft.enabled) return "Enable legacy import before permitting source deletion."; if (!existingCredential && !canManageSecrets) return "Managing the encrypted POP3 credential requires Mail secret authority."; if (!existingCredential && !draft.username.trim()) return "Enter the POP3 username."; if (!existingCredential && !draft.password) return "Enter the POP3 password."; return ""; } function boundedInteger(value: string, minimum: number, maximum: number): boolean { const parsed = Number(value); return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum; } function deletionTone(status: string): "success" | "warning" | "error" | "inactive" { if (status === "succeeded") return "success"; if (status === "failed" || status === "outcome_unknown") return "error"; if (status === "pending") return "warning"; return "inactive"; } function formatBytes(value: number): string { if (value < 1024) return `${value} B`; if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`; return `${(value / 1024 / 1024).toFixed(1)} MiB`; }