625 lines
32 KiB
TypeScript
625 lines
32 KiB
TypeScript
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<MailServerProfile[]>([]);
|
|
const [imports, setImports] = useState<MailPop3ImportRecord[]>([]);
|
|
const [selectedSourceId, setSelectedSourceId] = useState("");
|
|
const [preview, setPreview] = useState<MailPop3PreviewResponse | null>(null);
|
|
const [selectedUidls, setSelectedUidls] = useState<string[]>([]);
|
|
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<string | null>(null);
|
|
const [sourceDraft, setSourceDraft] = useState<SourceDraft>(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<MailPop3MessagePreview>[] = [
|
|
{
|
|
id: "select",
|
|
header: "Select",
|
|
width: 82,
|
|
render: (item) => <input
|
|
type="checkbox"
|
|
aria-label={`Select ${item.subject || item.uidl}`}
|
|
data-help-context-id="mail.pop3.field.message-selection"
|
|
data-help-module-id="mail"
|
|
checked={selectedUidls.includes(item.uidl)}
|
|
disabled={Boolean(busy) || item.already_imported}
|
|
onChange={() => 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) => <span><strong>{item.subject || "No subject"}</strong><br /><small>{item.from_header || "Unknown sender"}</small></span>
|
|
},
|
|
{ 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) => <StatusBadge status={item.already_imported ? "inactive" : "success"} label={item.already_imported ? "imported" : "available"} />
|
|
}
|
|
];
|
|
|
|
const importColumns: DataGridColumn<MailPop3ImportRecord>[] = [
|
|
{ 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) => <span><strong>{item.subject || "No subject"}</strong><br /><small>{item.from_header || "Unknown sender"}</small></span> },
|
|
{ id: "review", header: "Review state", width: 140, value: (item) => item.status, render: (item) => <StatusBadge status="warning" label={item.status.replaceAll("_", " ")} /> },
|
|
{ id: "deletion", header: "Source deletion", width: 165, value: (item) => item.deletion_status, render: (item) => <StatusBadge status={deletionTone(item.deletion_status)} label={item.deletion_status.replaceAll("_", " ")} /> }
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<PageLayout
|
|
archetype="collection"
|
|
title="Legacy POP3 import"
|
|
description="Migrate bounded messages into encrypted local review records. POP3 is disabled by default and is not recommended for ongoing mailbox access."
|
|
helpContextId="mail.pop3"
|
|
helpModuleId="mail"
|
|
helpTopicId="mail.workflow.legacy-pop3-import"
|
|
actions={<PageActionBar
|
|
variant="collection"
|
|
refreshable
|
|
reloadAction={{ onReload: () => 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={<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>}
|
|
helpAction={<DocumentationHelpLink reference={DOCUMENTATION} />}
|
|
createAction={canManage ? <Button helpContextId="mail.pop3.action.create-source" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" variant="primary" onClick={() => openSourceDialog(null)} disabled={Boolean(busy) || configurableProfiles.length === 0} disabledReason={configurableProfiles.length === 0 ? "Create or gain write access to a Mail profile first." : undefined}><Plus size={16} aria-hidden="true" /> Add legacy source</Button> : undefined}
|
|
/>}
|
|
loading={loading}
|
|
loadingLabel="Loading legacy POP3 sources"
|
|
error={error}
|
|
success={success}
|
|
documentationType="admin"
|
|
>
|
|
<ContentGrid columns={2} collapseAt="workspace" align="stretch">
|
|
<Card title="Legacy sources">
|
|
{sources.length === 0 ? <ActionBlockerHint reason={{
|
|
summary: "No POP3 legacy source is configured.",
|
|
details: "Mail never derives or enables POP3 from an SMTP or IMAP profile.",
|
|
requiredAction: canManage ? "Add a dedicated legacy source to an existing Mail profile." : "Ask a Mail profile administrator to configure and explicitly enable a source.",
|
|
actor: "Mail profile administrator",
|
|
target: "Legacy POP3 import"
|
|
}} documentation={DOCUMENTATION} /> : <SelectionList label="POP3 legacy sources" variant="navigation">
|
|
{sources.map((source) => <SelectionListItem key={source.server.id} selected={source.server.id === selectedSource?.server.id} onClick={() => setSelectedSourceId(source.server.id)}>
|
|
<SelectionListItemContent
|
|
title={source.server.name}
|
|
description={`${source.profile.name} · ${source.server.config.host || "Host missing"}`}
|
|
leading={<ShieldCheck size={18} />}
|
|
/>
|
|
</SelectionListItem>)}
|
|
</SelectionList>}
|
|
</Card>
|
|
|
|
<Card
|
|
title={selectedSource?.server.name || "Selected source"}
|
|
actions={selectedSource && canManage && sourceCanBeConfigured ? <TableActionGroup actions={[{
|
|
id: "edit",
|
|
label: "Configure source",
|
|
icon: <Pencil aria-hidden="true" />,
|
|
disabled: Boolean(busy),
|
|
disabledReason: busy ? "Wait for the current POP3 action to finish." : "",
|
|
helpContextId: "mail.pop3.action.configure-source",
|
|
helpModuleId: "mail",
|
|
helpTopicId: "mail.workflow.legacy-pop3-import",
|
|
onClick: () => openSourceDialog(selectedSource)
|
|
}]} /> : undefined}
|
|
>
|
|
{selectedSource ? <FormGrid columns={2} collapseAt="standard">
|
|
<FormField label="Profile"><span>{selectedSource.profile.name}</span></FormField>
|
|
<FormField label="Policy"><StatusBadge status={selectedSource.server.config.legacy_import_enabled ? "success" : "inactive"} label={selectedSource.server.config.legacy_import_enabled ? "explicitly enabled" : "disabled"} /></FormField>
|
|
<FormField label="Transport"><span>{selectedSource.server.config.security || "tls"} · {String(selectedSource.server.config.port || 995)}</span></FormField>
|
|
<FormField label="Credential"><span>{selectedCredential ? String(selectedCredential.public_data?.username || selectedCredential.name) : "No credential"}</span></FormField>
|
|
<Button helpContextId="mail.pop3.action.test" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" onClick={() => void runConnectionTest()} disabled={Boolean(busy) || !selectedCredential || !selectedSource.server.config.legacy_import_enabled} disabledReason={!selectedCredential ? "Store an encrypted POP3 credential first." : !selectedSource.server.config.legacy_import_enabled ? "Explicitly enable legacy import first." : busy ? "Wait for the current POP3 action to finish." : undefined}>Test connection</Button>
|
|
{canImport ? <Button helpContextId="mail.pop3.action.preview" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" variant="primary" onClick={() => void refreshPreview()} disabled={Boolean(busy) || !selectedCredential || !selectedSource.server.config.legacy_import_enabled} disabledReason={!selectedCredential ? "Store an encrypted POP3 credential first." : !selectedSource.server.config.legacy_import_enabled ? "Explicitly enable legacy import first." : busy ? "Wait for the current POP3 action to finish." : undefined}>Refresh live preview</Button> : null}
|
|
</FormGrid> : <p className="muted">Select or configure a legacy source.</p>}
|
|
</Card>
|
|
</ContentGrid>
|
|
|
|
<Card title="Live provider preview">
|
|
{preview ? <>
|
|
<p className="muted">Provider reports {preview.message_count} message(s), {formatBytes(preview.mailbox_size_bytes)} total. Preview and ordinary import do not delete source messages.</p>
|
|
<DataGrid id="mail-pop3-preview" rows={preview.messages} columns={previewColumns} getRowKey={(item) => item.uidl} emptyText="The legacy mailbox contains no messages." />
|
|
<FormGrid columns={2} collapseAt="standard" spacing="block">
|
|
<ToggleSwitch helpContextId="mail.pop3.field.delete-after-import" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" checked={deleteAfterImport} disabled={!canDelete || !preview.delete_after_import_allowed || Boolean(busy)} onChange={setDeleteAfterImport} label="Delete newly imported messages at the source" help="Destructive and separately governed. The local encrypted import is committed first." />
|
|
<Button helpContextId="mail.pop3.action.import" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" variant={deleteAfterImport ? "danger" : "primary"} onClick={() => deleteAfterImport ? setDeleteConfirmationOpen(true) : void runImport()} disabled={Boolean(busy) || Boolean(importBlocker)} disabledReason={importBlocker || (busy ? "Wait for the current POP3 action to finish." : undefined)}>Import {selectedUidls.length || "selected"} message(s)</Button>
|
|
</FormGrid>
|
|
</> : <p className="muted">Refresh a source to obtain a bounded, non-destructive preview.</p>}
|
|
</Card>
|
|
|
|
<Card title="Governed local imports">
|
|
<DataGrid id="mail-pop3-imports" rows={imports} columns={importColumns} getRowKey={(item) => item.id} emptyText="No legacy messages have been imported." />
|
|
</Card>
|
|
</PageLayout>
|
|
|
|
<Dialog
|
|
open={sourceDialogOpen}
|
|
title={editingSourceId ? "Configure legacy POP3 source" : "Add legacy POP3 source"}
|
|
helpContextId="mail.pop3.source-editor"
|
|
helpModuleId="mail"
|
|
helpTopicId="mail.workflow.legacy-pop3-import"
|
|
onClose={() => !busy && setSourceDialogOpen(false)}
|
|
footer={<><Button onClick={() => setSourceDialogOpen(false)} disabled={Boolean(busy)}>Cancel</Button><Button helpContextId="mail.pop3.action.save-source" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" variant="primary" onClick={() => void saveSource()} disabled={Boolean(busy) || Boolean(sourceBlocker)} disabledReason={sourceBlocker || (busy ? "Wait for the source to be saved." : undefined)}>Save source</Button></>}
|
|
>
|
|
<FormGrid columns={2} collapseAt="standard">
|
|
<FormField label="Mail profile" help="The profile supplies scope and lifecycle ownership for this dedicated source." helpContextId="mail.pop3.field.profile" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import">
|
|
<select value={sourceDraft.profileId} disabled={Boolean(editingSourceId) || Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, profileId: event.target.value }))}>
|
|
<option value="">Select a profile</option>
|
|
{configurableProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profile.scope_type})</option>)}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Source name" helpContextId="mail.pop3.field.name" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input value={sourceDraft.name} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, name: event.target.value }))} /></FormField>
|
|
<FormField label="POP3 host" helpContextId="mail.pop3.field.host" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input value={sourceDraft.host} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, host: event.target.value }))} placeholder="pop3.example.org" /></FormField>
|
|
<FormField label="Port" helpContextId="mail.pop3.field.port" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={1} max={65535} value={sourceDraft.port} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, port: event.target.value }))} /></FormField>
|
|
<FormField label="Transport security" helpContextId="mail.pop3.field.transport-security" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import">
|
|
<select value={sourceDraft.security} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, security: event.target.value as SourceDraft["security"], port: event.target.value === "tls" ? "995" : "110" }))}>
|
|
<option value="tls">TLS</option>
|
|
<option value="starttls">STARTTLS</option>
|
|
<option value="plain">Plain (deployment policy may deny)</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Timeout (seconds)" helpContextId="mail.pop3.field.timeout" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={1} max={300} value={sourceDraft.timeoutSeconds} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, timeoutSeconds: event.target.value }))} /></FormField>
|
|
<FormField label="Maximum message size (MiB)" helpContextId="mail.pop3.field.max-message-size" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={1} max={50} value={sourceDraft.maxMessageMiB} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, maxMessageMiB: event.target.value }))} /></FormField>
|
|
<FormField label="Maximum batch size (MiB)" helpContextId="mail.pop3.field.max-batch-size" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={1} max={500} value={sourceDraft.maxBatchMiB} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, maxBatchMiB: event.target.value }))} /></FormField>
|
|
<FormField label="Preview body lines" helpContextId="mail.pop3.field.preview-body-lines" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={0} max={100} value={sourceDraft.previewBodyLines} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, previewBodyLines: event.target.value }))} /></FormField>
|
|
<FormField label="Username" helpContextId="mail.pop3.field.username" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input value={sourceDraft.username} disabled={Boolean(busy) || !canManageSecrets} onChange={(event) => setSourceDraft((draft) => ({ ...draft, username: event.target.value }))} autoComplete="username" /></FormField>
|
|
<FormField label={editingSourceId ? "New password (optional)" : "Password"} helpContextId="mail.pop3.field.password" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="password" value={sourceDraft.password} disabled={Boolean(busy) || !canManageSecrets} onChange={(event) => setSourceDraft((draft) => ({ ...draft, password: event.target.value }))} autoComplete="new-password" /></FormField>
|
|
<ToggleSwitch helpContextId="mail.pop3.field.enabled" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" checked={sourceDraft.enabled} disabled={Boolean(busy)} onChange={(enabled) => setSourceDraft((draft) => ({ ...draft, enabled, allowDeleteAfterImport: enabled ? draft.allowDeleteAfterImport : false }))} label="Explicitly enable legacy import" help="Off is the product default." />
|
|
<ToggleSwitch helpContextId="mail.pop3.field.allow-delete-after-import" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" checked={sourceDraft.allowDeleteAfterImport} disabled={Boolean(busy) || !sourceDraft.enabled} onChange={(allowDeleteAfterImport) => setSourceDraft((draft) => ({ ...draft, allowDeleteAfterImport }))} label="Permit delete-after-import requests" help="Operators still need a separate destructive permission and must choose deletion per batch." />
|
|
</FormGrid>
|
|
</Dialog>
|
|
|
|
<ConfirmDialog
|
|
open={deleteConfirmationOpen}
|
|
title="Import and delete source messages"
|
|
message={`Mail will first commit and audit ${selectedUidls.length} encrypted local import(s), then ask the POP3 server to delete only those newly imported messages. Provider deletion cannot be undone and may require reconciliation if its outcome is unknown.`}
|
|
confirmLabel="Import, then delete source"
|
|
helpContextId="mail.pop3.confirm-delete-source"
|
|
helpModuleId="mail"
|
|
helpTopicId="mail.workflow.legacy-pop3-import"
|
|
tone="danger"
|
|
busy={Boolean(busy)}
|
|
onCancel={() => 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<string, unknown> {
|
|
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`;
|
|
}
|